@willwade/aac-processors 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +44 -41
  2. package/dist/cli/index.js +7 -0
  3. package/dist/core/analyze.js +1 -0
  4. package/dist/core/treeStructure.d.ts +45 -2
  5. package/dist/core/treeStructure.js +22 -3
  6. package/dist/index.d.ts +2 -1
  7. package/dist/index.js +20 -3
  8. package/dist/{analytics → optional/analytics}/history.d.ts +15 -4
  9. package/dist/{analytics → optional/analytics}/history.js +3 -3
  10. package/dist/optional/analytics/index.d.ts +30 -0
  11. package/dist/optional/analytics/index.js +78 -0
  12. package/dist/optional/analytics/metrics/comparison.d.ts +36 -0
  13. package/dist/optional/analytics/metrics/comparison.js +334 -0
  14. package/dist/optional/analytics/metrics/core.d.ts +45 -0
  15. package/dist/optional/analytics/metrics/core.js +575 -0
  16. package/dist/optional/analytics/metrics/effort.d.ts +147 -0
  17. package/dist/optional/analytics/metrics/effort.js +211 -0
  18. package/dist/optional/analytics/metrics/index.d.ts +15 -0
  19. package/dist/optional/analytics/metrics/index.js +36 -0
  20. package/dist/optional/analytics/metrics/obl-types.d.ts +93 -0
  21. package/dist/optional/analytics/metrics/obl-types.js +7 -0
  22. package/dist/optional/analytics/metrics/obl.d.ts +40 -0
  23. package/dist/optional/analytics/metrics/obl.js +287 -0
  24. package/dist/optional/analytics/metrics/sentence.d.ts +49 -0
  25. package/dist/optional/analytics/metrics/sentence.js +112 -0
  26. package/dist/optional/analytics/metrics/types.d.ts +157 -0
  27. package/dist/optional/analytics/metrics/types.js +7 -0
  28. package/dist/optional/analytics/metrics/vocabulary.d.ts +65 -0
  29. package/dist/optional/analytics/metrics/vocabulary.js +142 -0
  30. package/dist/optional/analytics/reference/index.d.ts +51 -0
  31. package/dist/optional/analytics/reference/index.js +102 -0
  32. package/dist/optional/analytics/utils/idGenerator.d.ts +59 -0
  33. package/dist/optional/analytics/utils/idGenerator.js +96 -0
  34. package/dist/optional/symbolTools.js +13 -16
  35. package/dist/processors/astericsGridProcessor.d.ts +15 -0
  36. package/dist/processors/astericsGridProcessor.js +17 -0
  37. package/dist/processors/gridset/helpers.d.ts +4 -1
  38. package/dist/processors/gridset/helpers.js +4 -0
  39. package/dist/processors/gridset/pluginTypes.js +51 -50
  40. package/dist/processors/gridset/symbolExtractor.js +3 -2
  41. package/dist/processors/gridset/symbolSearch.js +9 -7
  42. package/dist/processors/gridsetProcessor.js +82 -20
  43. package/dist/processors/index.d.ts +1 -0
  44. package/dist/processors/index.js +5 -3
  45. package/dist/processors/obfProcessor.js +37 -2
  46. package/dist/processors/obfsetProcessor.d.ts +26 -0
  47. package/dist/processors/obfsetProcessor.js +179 -0
  48. package/dist/processors/snap/helpers.d.ts +5 -1
  49. package/dist/processors/snap/helpers.js +5 -0
  50. package/dist/processors/snapProcessor.d.ts +2 -0
  51. package/dist/processors/snapProcessor.js +184 -5
  52. package/dist/processors/touchchatProcessor.js +50 -4
  53. package/dist/types/aac.d.ts +67 -0
  54. package/dist/types/aac.js +33 -0
  55. package/dist/validation/gridsetValidator.js +10 -0
  56. package/package.json +1 -1
@@ -0,0 +1,575 @@
1
+ "use strict";
2
+ /**
3
+ * Core Metrics Analysis Engine
4
+ *
5
+ * Implements the main BFS traversal algorithm from the Ruby aac-metrics tool.
6
+ * Calculates effort scores for all buttons in an AAC board set.
7
+ *
8
+ * Based on: aac-metrics/lib/aac-metrics/metrics.rb
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.MetricsCalculator = void 0;
12
+ const treeStructure_1 = require("../../../core/treeStructure");
13
+ const aac_1 = require("../../../types/aac");
14
+ const effort_1 = require("./effort");
15
+ class MetricsCalculator {
16
+ constructor() {
17
+ this.locale = 'en';
18
+ }
19
+ /**
20
+ * Main analysis function - calculates metrics for an AAC tree
21
+ *
22
+ * @param tree - The AAC tree to analyze
23
+ * @returns Complete metrics result
24
+ */
25
+ analyze(tree) {
26
+ // Get root board - prioritize tree.rootId, then fall back to boards with no parentId
27
+ let rootBoard;
28
+ if (tree.rootId) {
29
+ rootBoard = tree.pages[tree.rootId];
30
+ }
31
+ if (!rootBoard) {
32
+ rootBoard = Object.values(tree.pages).find((p) => !p.parentId);
33
+ }
34
+ if (!rootBoard) {
35
+ throw new Error('No root board found in tree');
36
+ }
37
+ this.locale = rootBoard.locale || 'en';
38
+ // Step 1: Build semantic/clone reference maps
39
+ const { setRefs, setPcts } = this.buildReferenceMaps(tree);
40
+ // Step 2: BFS traversal from root board
41
+ const startBoards = [rootBoard];
42
+ // Find boards with temporary_home settings
43
+ Object.values(tree.pages).forEach((board) => {
44
+ board.buttons.forEach((btn) => {
45
+ if (btn.targetPageId && btn.semanticAction) {
46
+ // Check for temporary_home in platformData or fallback
47
+ const tempHome = btn.semanticAction.platformData?.grid3?.parameters?.temporary_home ||
48
+ btn.semanticAction.fallback?.temporary_home;
49
+ if (tempHome === 'prior') {
50
+ startBoards.push(board);
51
+ }
52
+ else if (tempHome === true && btn.targetPageId) {
53
+ const targetBoard = tree.getPage(btn.targetPageId);
54
+ if (targetBoard && !startBoards.includes(targetBoard)) {
55
+ startBoards.push(targetBoard);
56
+ }
57
+ }
58
+ }
59
+ });
60
+ });
61
+ const knownButtons = new Map();
62
+ const levels = {};
63
+ let totalButtons = 0;
64
+ // Analyze from each starting board
65
+ startBoards.forEach((startBoard) => {
66
+ const result = this.analyzeFrom(tree, startBoard, setPcts, startBoard === rootBoard);
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
+ // Convert to array and sort
82
+ const buttons = Array.from(knownButtons.values()).sort((a, b) => a.effort - b.effort);
83
+ // Calculate grid dimensions
84
+ const grid = this.calculateGridDimensions(tree);
85
+ return {
86
+ analysis_version: '0.2',
87
+ locale: this.locale,
88
+ total_boards: Object.keys(tree.pages).length,
89
+ total_buttons: totalButtons,
90
+ total_words: buttons.length,
91
+ reference_counts: setRefs,
92
+ grid,
93
+ buttons,
94
+ levels,
95
+ };
96
+ }
97
+ /**
98
+ * Build reference maps for semantic_id and clone_id frequencies
99
+ */
100
+ buildReferenceMaps(tree) {
101
+ const setRefs = {};
102
+ const cellRefs = {};
103
+ let rootRows = 0;
104
+ let rootCols = 0;
105
+ // First pass: calculate dimensions and count references
106
+ Object.values(tree.pages).forEach((board) => {
107
+ rootRows = rootRows || board.grid.length;
108
+ rootCols = rootCols || board.grid[0]?.length || 0;
109
+ // Count semantic_id and clone_id occurrences from board properties (upstream)
110
+ board.semantic_ids?.forEach((id) => {
111
+ setRefs[id] = (setRefs[id] || 0) + 1;
112
+ });
113
+ board.clone_ids?.forEach((id) => {
114
+ setRefs[id] = (setRefs[id] || 0) + 1;
115
+ });
116
+ // Count cell references
117
+ for (let r = 0; r < board.grid.length; r++) {
118
+ for (let c = 0; c < (board.grid[r]?.length || 0); c++) {
119
+ const ref = `${r}.${c}`;
120
+ const hasButton = board.grid[r][c] !== null;
121
+ cellRefs[ref] = (cellRefs[ref] || 0) + (hasButton ? 1.0 : 0.25);
122
+ }
123
+ }
124
+ });
125
+ // Calculate percentages
126
+ const setPcts = {};
127
+ const totalBoards = Object.keys(tree.pages).length;
128
+ Object.entries(setRefs).forEach(([id, count]) => {
129
+ // Extract location from ID (Ruby uses id.split(/-/)[1])
130
+ const parts = id.split('-');
131
+ if (parts.length >= 2) {
132
+ const loc = parts[1];
133
+ const cellCount = cellRefs[loc] || totalBoards;
134
+ setPcts[id] = count / cellCount;
135
+ if (setPcts[id] > 1.0) {
136
+ // console.log(`⚠️ setPcts[${id}] = ${setPcts[id].toFixed(4)} (count=${count}, cellCount=${cellCount.toFixed(2)})`);
137
+ setPcts[id] = 1.0; // Cap at 1.0 like Ruby effectively does
138
+ }
139
+ }
140
+ else {
141
+ setPcts[id] = count / totalBoards;
142
+ }
143
+ });
144
+ return { setRefs, setPcts };
145
+ }
146
+ /**
147
+ * Count scan items for visual scanning effort
148
+ * When block scanning is enabled, count unique scan blocks instead of individual buttons
149
+ */
150
+ countScanItems(board, currentRowIndex, currentColIndex, priorScanBlocks, blockScanEnabled) {
151
+ if (!blockScanEnabled) {
152
+ // Linear scanning: count all buttons before current position
153
+ let count = 0;
154
+ for (let r = 0; r <= currentRowIndex; r++) {
155
+ const row = board.grid[r];
156
+ if (!row)
157
+ continue;
158
+ for (let c = 0; c < row.length; c++) {
159
+ if (r === currentRowIndex && c === currentColIndex)
160
+ return count;
161
+ const btn = row[c];
162
+ if (btn && (btn.label || btn.id).length > 0) {
163
+ count++;
164
+ }
165
+ }
166
+ }
167
+ return count;
168
+ }
169
+ // Block scanning: count unique scan blocks before current position
170
+ const seenBlocks = new Set();
171
+ for (let r = 0; r <= currentRowIndex; r++) {
172
+ const row = board.grid[r];
173
+ if (!row)
174
+ continue;
175
+ for (let c = 0; c < row.length; c++) {
176
+ if (r === currentRowIndex && c === currentColIndex)
177
+ return seenBlocks.size;
178
+ const btn = row[c];
179
+ if (btn && (btn.label || btn.id).length > 0) {
180
+ const block = btn.scanBlock ||
181
+ (btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null);
182
+ if (block !== null)
183
+ seenBlocks.add(block);
184
+ }
185
+ }
186
+ }
187
+ return seenBlocks.size;
188
+ }
189
+ /**
190
+ * Analyze starting from a specific board
191
+ */
192
+ analyzeFrom(tree, brd, setPcts, _isRoot) {
193
+ const visitedBoardIds = new Map();
194
+ const toVisit = [
195
+ {
196
+ board: brd,
197
+ level: 0,
198
+ entryX: 1.0,
199
+ entryY: 1.0,
200
+ },
201
+ ];
202
+ const knownButtons = new Map();
203
+ const levels = {};
204
+ while (toVisit.length > 0) {
205
+ const item = toVisit.shift();
206
+ if (!item)
207
+ break;
208
+ const { board, level, entryX, entryY, priorEffort = 0, temporaryHomeId } = item;
209
+ // Skip if already visited at a lower level with equal or better prior effort
210
+ // Skip if already visited at a strictly lower level
211
+ const existingLevel = visitedBoardIds.get(board.id);
212
+ if (existingLevel !== undefined && existingLevel < level) {
213
+ continue;
214
+ }
215
+ visitedBoardIds.set(board.id, level);
216
+ const rows = board.grid.length;
217
+ const cols = board.grid[0]?.length || 0;
218
+ // Calculate board-level effort
219
+ // Ruby uses grid size (rows * cols) for field size effort
220
+ const gridSize = rows * cols;
221
+ let boardEffort = (0, effort_1.baseBoardEffort)(rows, cols, gridSize);
222
+ // Apply reuse discounts - iterate through grid cells like Ruby does
223
+ let reuseDiscount = 0.0;
224
+ board.grid.forEach((row) => {
225
+ row.forEach((btn) => {
226
+ if (!btn)
227
+ return;
228
+ if (btn.clone_id && setPcts[btn.clone_id]) {
229
+ reuseDiscount += effort_1.EFFORT_CONSTANTS.REUSED_CLONE_FROM_OTHER_BONUS * setPcts[btn.clone_id];
230
+ }
231
+ else if (btn.semantic_id && setPcts[btn.semantic_id]) {
232
+ reuseDiscount +=
233
+ effort_1.EFFORT_CONSTANTS.REUSED_SEMANTIC_FROM_OTHER_BONUS * setPcts[btn.semantic_id];
234
+ }
235
+ });
236
+ });
237
+ boardEffort = Math.max(0, boardEffort - reuseDiscount);
238
+ // Calculate board link percentages
239
+ const boardPcts = this.calculateBoardLinkPercentages(tree, board);
240
+ // Get scanning configuration from page (if available)
241
+ const blockScanEnabled = board.scanningConfig?.blockScanEnabled || false;
242
+ // Process each button
243
+ const priorScanBlocks = new Set();
244
+ const btnHeight = 1.0 / rows;
245
+ 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;
252
+ }
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)}`);
267
+ }
268
+ // Apply semantic_id discounts
269
+ if (btn.semantic_id && boardPcts[btn.semantic_id]) {
270
+ 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)})`);
283
+ }
284
+ // Apply clone_id discounts
285
+ if (btn.clone_id && boardPcts[btn.clone_id]) {
286
+ 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);
293
+ }
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]) {
301
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
302
+ sEffort = Math.min(sEffort, sEffort * discount);
303
+ }
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);
307
+ }
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
+ }
329
+ }
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
+ }
344
+ }
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);
350
+ }
351
+ else {
352
+ buttonEffort += (0, effort_1.localScanEffort)(distance);
353
+ }
354
+ }
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
+ }
363
+ }
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
+ }
390
+ }
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);
425
+ }
426
+ }
427
+ });
428
+ });
429
+ }
430
+ // Convert to array and group by level
431
+ const buttons = Array.from(knownButtons.values());
432
+ buttons.forEach((btn) => {
433
+ if (!levels[btn.level]) {
434
+ levels[btn.level] = [];
435
+ }
436
+ levels[btn.level].push(btn);
437
+ });
438
+ return {
439
+ buttons,
440
+ levels,
441
+ totalButtons: buttons.length,
442
+ };
443
+ }
444
+ /**
445
+ * Calculate what percentage of links to this board match semantic_id/clone_id
446
+ */
447
+ calculateBoardLinkPercentages(tree, board) {
448
+ const boardPcts = {};
449
+ let totalLinks = 0;
450
+ Object.values(tree.pages).forEach((sourceBoard) => {
451
+ sourceBoard.buttons.forEach((btn) => {
452
+ if (btn.targetPageId === board.id) {
453
+ totalLinks++;
454
+ if (btn.semantic_id) {
455
+ boardPcts[btn.semantic_id] = (boardPcts[btn.semantic_id] || 0) + 1;
456
+ }
457
+ if (btn.clone_id) {
458
+ boardPcts[btn.clone_id] = (boardPcts[btn.clone_id] || 0) + 1;
459
+ }
460
+ // Also count IDs present on the source board that links to this one
461
+ sourceBoard.semantic_ids?.forEach((id) => {
462
+ boardPcts[`upstream-${id}`] = (boardPcts[`upstream-${id}`] || 0) + 1;
463
+ });
464
+ sourceBoard.clone_ids?.forEach((id) => {
465
+ boardPcts[`upstream-${id}`] = (boardPcts[`upstream-${id}`] || 0) + 1;
466
+ });
467
+ }
468
+ });
469
+ });
470
+ // Convert counts to percentages
471
+ if (totalLinks > 0) {
472
+ Object.keys(boardPcts).forEach((id) => {
473
+ boardPcts[id] = boardPcts[id] / totalLinks;
474
+ });
475
+ }
476
+ boardPcts['all'] = totalLinks;
477
+ return boardPcts;
478
+ }
479
+ /**
480
+ * Calculate grid dimensions from the tree
481
+ */
482
+ calculateGridDimensions(tree) {
483
+ let totalRows = 0;
484
+ let totalCols = 0;
485
+ let count = 0;
486
+ Object.values(tree.pages).forEach((page) => {
487
+ totalRows += page.grid.length;
488
+ totalCols += page.grid[0]?.length || 0;
489
+ count++;
490
+ });
491
+ return {
492
+ rows: Math.round(totalRows / count),
493
+ columns: Math.round(totalCols / count),
494
+ };
495
+ }
496
+ /**
497
+ * Calculate scanning steps and selections for a button based on access method
498
+ */
499
+ calculateScanSteps(board, btn, rowIndex, colIndex) {
500
+ // Determine scanning type from local scanType or scanningConfig
501
+ let type = board.scanType || treeStructure_1.AACScanType.LINEAR;
502
+ if (board.scanningConfig?.cellScanningOrder) {
503
+ const order = board.scanningConfig.cellScanningOrder;
504
+ // String matching for CellScanningOrder
505
+ if (order === aac_1.CellScanningOrder.RowColumnScan)
506
+ type = treeStructure_1.AACScanType.ROW_COLUMN;
507
+ else if (order === aac_1.CellScanningOrder.ColumnRowScan)
508
+ type = treeStructure_1.AACScanType.COLUMN_ROW;
509
+ else if (order === aac_1.CellScanningOrder.SimpleScanColumnsFirst)
510
+ type = treeStructure_1.AACScanType.COLUMN_ROW;
511
+ else if (order === aac_1.CellScanningOrder.SimpleScan)
512
+ type = treeStructure_1.AACScanType.LINEAR;
513
+ }
514
+ // Force block scan if enabled in config
515
+ const isBlockScan = board.scanningConfig?.blockScanEnabled ||
516
+ type === treeStructure_1.AACScanType.BLOCK_ROW_COLUMN ||
517
+ type === treeStructure_1.AACScanType.BLOCK_COLUMN_ROW;
518
+ if (isBlockScan) {
519
+ const blockId = btn.scanBlock || (btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null);
520
+ // If no block assigned, treat as its own block at the end (fallback)
521
+ if (blockId === null) {
522
+ return { steps: rowIndex + colIndex + 1, selections: 1 };
523
+ }
524
+ const config = board.scanBlocksConfig?.find((c) => c.id === blockId);
525
+ const blockOrder = config?.order ?? blockId;
526
+ // Linear scan within the block
527
+ let btnInBlockIndex = 0;
528
+ let found = false;
529
+ for (let r = 0; r < board.grid.length; r++) {
530
+ for (let c = 0; c < (board.grid[r]?.length || 0); c++) {
531
+ 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;
536
+ }
537
+ btnInBlockIndex++;
538
+ }
539
+ }
540
+ if (found)
541
+ break;
542
+ }
543
+ // 1 selection for block, 1 for item
544
+ return { steps: blockOrder + btnInBlockIndex + 1, selections: 2 };
545
+ }
546
+ switch (type) {
547
+ case treeStructure_1.AACScanType.LINEAR: {
548
+ let index = 0;
549
+ let found = false;
550
+ for (let r = 0; r < board.grid.length; r++) {
551
+ for (let c = 0; c < board.grid[r].length; c++) {
552
+ const b = board.grid[r][c];
553
+ if (b && (b.label || '').length > 0) {
554
+ if (b === btn) {
555
+ found = true;
556
+ break;
557
+ }
558
+ index++;
559
+ }
560
+ }
561
+ if (found)
562
+ break;
563
+ }
564
+ return { steps: index + 1, selections: 1 };
565
+ }
566
+ case treeStructure_1.AACScanType.ROW_COLUMN:
567
+ return { steps: rowIndex + 1 + (colIndex + 1), selections: 2 };
568
+ case treeStructure_1.AACScanType.COLUMN_ROW:
569
+ return { steps: colIndex + 1 + (rowIndex + 1), selections: 2 };
570
+ default:
571
+ return { steps: rowIndex + 1 + (colIndex + 1), selections: 2 };
572
+ }
573
+ }
574
+ }
575
+ exports.MetricsCalculator = MetricsCalculator;