@willwade/aac-processors 0.0.10 → 0.0.12

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 (57) hide show
  1. package/dist/cli/index.js +7 -0
  2. package/dist/core/analyze.js +1 -0
  3. package/dist/core/baseProcessor.d.ts +3 -0
  4. package/dist/core/treeStructure.d.ts +14 -2
  5. package/dist/core/treeStructure.js +8 -2
  6. package/dist/index.d.ts +2 -1
  7. package/dist/index.js +20 -3
  8. package/dist/{analytics → optional/analytics}/history.d.ts +3 -3
  9. package/dist/{analytics → optional/analytics}/history.js +3 -3
  10. package/dist/optional/analytics/index.d.ts +28 -0
  11. package/dist/optional/analytics/index.js +73 -0
  12. package/dist/optional/analytics/metrics/comparison.d.ts +36 -0
  13. package/dist/optional/analytics/metrics/comparison.js +330 -0
  14. package/dist/optional/analytics/metrics/core.d.ts +36 -0
  15. package/dist/optional/analytics/metrics/core.js +422 -0
  16. package/dist/optional/analytics/metrics/effort.d.ts +137 -0
  17. package/dist/optional/analytics/metrics/effort.js +198 -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/sentence.d.ts +49 -0
  21. package/dist/optional/analytics/metrics/sentence.js +112 -0
  22. package/dist/optional/analytics/metrics/types.d.ts +157 -0
  23. package/dist/optional/analytics/metrics/types.js +7 -0
  24. package/dist/optional/analytics/metrics/vocabulary.d.ts +65 -0
  25. package/dist/optional/analytics/metrics/vocabulary.js +140 -0
  26. package/dist/optional/analytics/reference/index.d.ts +51 -0
  27. package/dist/optional/analytics/reference/index.js +102 -0
  28. package/dist/optional/analytics/utils/idGenerator.d.ts +59 -0
  29. package/dist/optional/analytics/utils/idGenerator.js +96 -0
  30. package/dist/processors/gridset/colorUtils.d.ts +18 -0
  31. package/dist/processors/gridset/colorUtils.js +36 -0
  32. package/dist/processors/gridset/commands.d.ts +103 -0
  33. package/dist/processors/gridset/commands.js +958 -0
  34. package/dist/processors/gridset/index.d.ts +45 -0
  35. package/dist/processors/gridset/index.js +153 -0
  36. package/dist/processors/gridset/pluginTypes.d.ts +109 -0
  37. package/dist/processors/gridset/pluginTypes.js +285 -0
  38. package/dist/processors/gridset/resolver.d.ts +13 -0
  39. package/dist/processors/gridset/resolver.js +39 -1
  40. package/dist/processors/gridset/styleHelpers.d.ts +22 -0
  41. package/dist/processors/gridset/styleHelpers.js +35 -1
  42. package/dist/processors/gridset/symbolExtractor.d.ts +121 -0
  43. package/dist/processors/gridset/symbolExtractor.js +362 -0
  44. package/dist/processors/gridset/symbolSearch.d.ts +117 -0
  45. package/dist/processors/gridset/symbolSearch.js +280 -0
  46. package/dist/processors/gridset/symbols.d.ts +199 -0
  47. package/dist/processors/gridset/symbols.js +468 -0
  48. package/dist/processors/gridsetProcessor.js +59 -0
  49. package/dist/processors/index.d.ts +10 -1
  50. package/dist/processors/index.js +93 -2
  51. package/dist/processors/obfProcessor.js +25 -2
  52. package/dist/processors/obfsetProcessor.d.ts +26 -0
  53. package/dist/processors/obfsetProcessor.js +179 -0
  54. package/dist/processors/snapProcessor.js +29 -1
  55. package/dist/processors/touchchatProcessor.js +27 -0
  56. package/dist/types/aac.d.ts +21 -0
  57. package/package.json +1 -1
@@ -0,0 +1,422 @@
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 effort_1 = require("./effort");
14
+ class MetricsCalculator {
15
+ constructor() {
16
+ this.locale = 'en';
17
+ }
18
+ /**
19
+ * Main analysis function - calculates metrics for an AAC tree
20
+ *
21
+ * @param tree - The AAC tree to analyze
22
+ * @returns Complete metrics result
23
+ */
24
+ analyze(tree) {
25
+ // Get root board - prioritize tree.rootId, then fall back to boards with no parentId
26
+ let rootBoard;
27
+ if (tree.rootId) {
28
+ rootBoard = tree.pages[tree.rootId];
29
+ }
30
+ if (!rootBoard) {
31
+ rootBoard = Object.values(tree.pages).find((p) => !p.parentId);
32
+ }
33
+ if (!rootBoard) {
34
+ throw new Error('No root board found in tree');
35
+ }
36
+ this.locale = rootBoard.locale || 'en';
37
+ // Step 1: Build semantic/clone reference maps
38
+ const { setRefs, setPcts } = this.buildReferenceMaps(tree);
39
+ // Step 2: BFS traversal from root board
40
+ const startBoards = [rootBoard];
41
+ // Find boards with temporary_home settings
42
+ Object.values(tree.pages).forEach((board) => {
43
+ board.buttons.forEach((btn) => {
44
+ if (btn.targetPageId && btn.semanticAction) {
45
+ // Check for temporary_home in platformData or fallback
46
+ const tempHome = btn.semanticAction.platformData?.grid3?.parameters?.temporary_home ||
47
+ btn.semanticAction.fallback?.temporary_home;
48
+ if (tempHome === 'prior') {
49
+ startBoards.push(board);
50
+ }
51
+ else if (tempHome === true && btn.targetPageId) {
52
+ const targetBoard = tree.getPage(btn.targetPageId);
53
+ if (targetBoard && !startBoards.includes(targetBoard)) {
54
+ startBoards.push(targetBoard);
55
+ }
56
+ }
57
+ }
58
+ });
59
+ });
60
+ const knownButtons = new Map();
61
+ const levels = {};
62
+ let totalButtons = 0;
63
+ // Analyze from each starting board
64
+ startBoards.forEach((startBoard) => {
65
+ const result = this.analyzeFrom(tree, startBoard, setPcts, startBoard === rootBoard);
66
+ result.buttons.forEach((btn) => {
67
+ const existing = knownButtons.get(btn.label);
68
+ if (!existing || btn.effort < existing.effort) {
69
+ knownButtons.set(btn.label, btn);
70
+ }
71
+ if (btn.count && existing && existing.count) {
72
+ btn.count += existing.count;
73
+ }
74
+ });
75
+ if (startBoard === rootBoard) {
76
+ Object.assign(levels, result.levels);
77
+ totalButtons = result.totalButtons;
78
+ }
79
+ });
80
+ // Convert to array and sort
81
+ const buttons = Array.from(knownButtons.values()).sort((a, b) => a.effort - b.effort);
82
+ // Calculate grid dimensions
83
+ const grid = this.calculateGridDimensions(tree);
84
+ return {
85
+ analysis_version: '0.2',
86
+ locale: this.locale,
87
+ total_boards: Object.keys(tree.pages).length,
88
+ total_buttons: totalButtons,
89
+ total_words: buttons.length,
90
+ reference_counts: setRefs,
91
+ grid,
92
+ buttons,
93
+ levels,
94
+ };
95
+ }
96
+ /**
97
+ * Build reference maps for semantic_id and clone_id frequencies
98
+ */
99
+ buildReferenceMaps(tree) {
100
+ const setRefs = {};
101
+ const cellRefs = {};
102
+ let rootRows = 0;
103
+ let rootCols = 0;
104
+ // First pass: calculate dimensions and count references
105
+ Object.values(tree.pages).forEach((board) => {
106
+ rootRows = rootRows || board.grid.length;
107
+ rootCols = rootCols || board.grid[0]?.length || 0;
108
+ // Count semantic_id and clone_id occurrences from board properties (upstream)
109
+ board.semantic_ids?.forEach((id) => {
110
+ setRefs[id] = (setRefs[id] || 0) + 1;
111
+ });
112
+ board.clone_ids?.forEach((id) => {
113
+ setRefs[id] = (setRefs[id] || 0) + 1;
114
+ });
115
+ // Count cell references
116
+ for (let r = 0; r < board.grid.length; r++) {
117
+ for (let c = 0; c < (board.grid[r]?.length || 0); c++) {
118
+ const ref = `${r}.${c}`;
119
+ const hasButton = board.grid[r][c] !== null;
120
+ cellRefs[ref] = (cellRefs[ref] || 0) + (hasButton ? 1.0 : 0.25);
121
+ }
122
+ }
123
+ });
124
+ // Calculate percentages
125
+ const setPcts = {};
126
+ const totalBoards = Object.keys(tree.pages).length;
127
+ Object.entries(setRefs).forEach(([id, count]) => {
128
+ // Extract location from ID (Ruby uses id.split(/-/)[1])
129
+ const parts = id.split('-');
130
+ if (parts.length >= 2) {
131
+ const loc = parts[1];
132
+ const cellCount = cellRefs[loc] || totalBoards;
133
+ setPcts[id] = count / cellCount;
134
+ if (setPcts[id] > 1.0) {
135
+ // console.log(`⚠️ setPcts[${id}] = ${setPcts[id].toFixed(4)} (count=${count}, cellCount=${cellCount.toFixed(2)})`);
136
+ setPcts[id] = 1.0; // Cap at 1.0 like Ruby effectively does
137
+ }
138
+ }
139
+ else {
140
+ setPcts[id] = count / totalBoards;
141
+ }
142
+ });
143
+ return { setRefs, setPcts };
144
+ }
145
+ /**
146
+ * Analyze starting from a specific board
147
+ */
148
+ analyzeFrom(tree, brd, setPcts, _isRoot) {
149
+ const visitedBoardIds = new Map();
150
+ const toVisit = [
151
+ {
152
+ board: brd,
153
+ level: 0,
154
+ entryX: 1.0,
155
+ entryY: 1.0,
156
+ },
157
+ ];
158
+ const knownButtons = new Map();
159
+ const levels = {};
160
+ while (toVisit.length > 0) {
161
+ const item = toVisit.shift();
162
+ const { board, level, entryX, entryY, priorEffort = 0, temporaryHomeId } = item;
163
+ // Skip if already visited at a lower level with equal or better prior effort
164
+ // Skip if already visited at a strictly lower level
165
+ const existingLevel = visitedBoardIds.get(board.id);
166
+ if (existingLevel !== undefined && existingLevel < level) {
167
+ continue;
168
+ }
169
+ visitedBoardIds.set(board.id, level);
170
+ const rows = board.grid.length;
171
+ const cols = board.grid[0]?.length || 0;
172
+ // Calculate board-level effort
173
+ // Ruby uses grid size (rows * cols) for field size effort
174
+ const gridSize = rows * cols;
175
+ let boardEffort = (0, effort_1.baseBoardEffort)(rows, cols, gridSize);
176
+ // Apply reuse discounts - iterate through grid cells like Ruby does
177
+ let reuseDiscount = 0.0;
178
+ board.grid.forEach((row) => {
179
+ row.forEach((btn) => {
180
+ if (!btn)
181
+ return;
182
+ if (btn.clone_id && setPcts[btn.clone_id]) {
183
+ reuseDiscount += effort_1.EFFORT_CONSTANTS.REUSED_CLONE_FROM_OTHER_BONUS * setPcts[btn.clone_id];
184
+ }
185
+ else if (btn.semantic_id && setPcts[btn.semantic_id]) {
186
+ reuseDiscount +=
187
+ effort_1.EFFORT_CONSTANTS.REUSED_SEMANTIC_FROM_OTHER_BONUS * setPcts[btn.semantic_id];
188
+ }
189
+ });
190
+ });
191
+ boardEffort = Math.max(0, boardEffort - reuseDiscount);
192
+ // Calculate board link percentages
193
+ const boardPcts = this.calculateBoardLinkPercentages(tree, board);
194
+ // Process each button
195
+ let priorButtons = 0;
196
+ const btnHeight = 1.0 / rows;
197
+ const btnWidth = 1.0 / cols;
198
+ board.grid.forEach((row, rowIndex) => {
199
+ row.forEach((btn, colIndex) => {
200
+ // Skip null buttons and buttons with empty labels (matching Ruby behavior)
201
+ if (!btn || (btn.label || '').length === 0) {
202
+ // Don't count these toward prior_buttons (Ruby uses "next unless")
203
+ return;
204
+ }
205
+ const x = btnWidth / 2 + btnWidth * colIndex;
206
+ const y = btnHeight / 2 + btnHeight * rowIndex;
207
+ // Calculate button-level effort
208
+ let buttonEffort = boardEffort;
209
+ // Debug for specific button (disabled for production)
210
+ const debugSpecificButton = btn.label === '$938c2cc0dc';
211
+ if (debugSpecificButton) {
212
+ console.log(`\n🔍 DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:`);
213
+ console.log(` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`);
214
+ console.log(` Current level: ${level}`);
215
+ console.log(` Starting effort: ${buttonEffort.toFixed(6)}`);
216
+ }
217
+ // Apply semantic_id discounts
218
+ if (btn.semantic_id && boardPcts[btn.semantic_id]) {
219
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
220
+ const old = buttonEffort;
221
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
222
+ if (debugSpecificButton)
223
+ console.log(` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})`);
224
+ }
225
+ else if (btn.semantic_id && boardPcts[`upstream-${btn.semantic_id}`]) {
226
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
227
+ boardPcts[`upstream-${btn.semantic_id}`];
228
+ const old = buttonEffort;
229
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
230
+ if (debugSpecificButton)
231
+ console.log(` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})`);
232
+ }
233
+ // Apply clone_id discounts
234
+ if (btn.clone_id && boardPcts[btn.clone_id]) {
235
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
236
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
237
+ }
238
+ else if (btn.clone_id && boardPcts[`upstream-${btn.clone_id}`]) {
239
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
240
+ boardPcts[`upstream-${btn.clone_id}`];
241
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
242
+ }
243
+ // Add distance effort
244
+ let distance = (0, effort_1.distanceEffort)(x, y, entryX, entryY);
245
+ // Apply distance discounts
246
+ if (btn.semantic_id) {
247
+ if (boardPcts[btn.semantic_id]) {
248
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
249
+ distance = Math.min(distance, distance * discount);
250
+ }
251
+ else if (boardPcts[`upstream-${btn.semantic_id}`]) {
252
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
253
+ boardPcts[`upstream-${btn.semantic_id}`];
254
+ distance = Math.min(distance, distance * discount);
255
+ }
256
+ else if (level > 0 && setPcts[btn.semantic_id]) {
257
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT /
258
+ setPcts[btn.semantic_id];
259
+ distance = Math.min(distance, distance * discount);
260
+ }
261
+ }
262
+ if (btn.clone_id) {
263
+ if (boardPcts[btn.clone_id]) {
264
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
265
+ distance = Math.min(distance, distance * discount);
266
+ }
267
+ else if (boardPcts[`upstream-${btn.clone_id}`]) {
268
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
269
+ boardPcts[`upstream-${btn.clone_id}`];
270
+ distance = Math.min(distance, distance * discount);
271
+ }
272
+ else if (level > 0 && setPcts[btn.clone_id]) {
273
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[btn.clone_id];
274
+ distance = Math.min(distance, distance * discount);
275
+ }
276
+ }
277
+ buttonEffort += distance;
278
+ // Add visual scan or local scan effort
279
+ if (distance > effort_1.EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN ||
280
+ (entryX === 1.0 && entryY === 1.0)) {
281
+ buttonEffort += (0, effort_1.visualScanEffort)(priorButtons);
282
+ }
283
+ else {
284
+ buttonEffort += (0, effort_1.localScanEffort)(distance);
285
+ }
286
+ // Add cumulative prior effort
287
+ buttonEffort += priorEffort;
288
+ priorButtons += 1;
289
+ // Handle navigation buttons
290
+ if (btn.targetPageId) {
291
+ const nextBoard = tree.getPage(btn.targetPageId);
292
+ if (nextBoard) {
293
+ // Only add to toVisit if this board hasn't been visited yet at any level
294
+ // The visitedBoardIds map stores the *lowest* level a board was visited.
295
+ // If it's already in the map, it means we've processed it or scheduled it at a lower level.
296
+ if (visitedBoardIds.get(nextBoard.id) === undefined) {
297
+ const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
298
+ const tempHomeId = btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === 'prior'
299
+ ? board.id
300
+ : btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === true
301
+ ? btn.targetPageId
302
+ : temporaryHomeId;
303
+ toVisit.push({
304
+ board: nextBoard,
305
+ level: level + 1,
306
+ priorEffort: buttonEffort + changeEffort,
307
+ temporaryHomeId: tempHomeId,
308
+ entryX: x,
309
+ entryY: y,
310
+ entryCloneId: btn.clone_id,
311
+ entrySemanticId: btn.semantic_id,
312
+ });
313
+ }
314
+ }
315
+ }
316
+ // Track word if it speaks or adds to sentence
317
+ const intent = String(btn.semanticAction?.intent || '');
318
+ const isSpeak = btn.semanticAction?.category === treeStructure_1.AACSemanticCategory.COMMUNICATION ||
319
+ intent === 'SPEAK_TEXT' ||
320
+ intent === 'SPEAK_IMMEDIATE' ||
321
+ intent === 'INSERT_TEXT' ||
322
+ btn.semanticAction?.fallback?.type === 'SPEAK';
323
+ const addToSentence = btn.semanticAction?.platformData?.grid3?.parameters?.add_to_sentence ||
324
+ btn.semanticAction?.fallback?.add_to_sentence;
325
+ if (isSpeak || addToSentence) {
326
+ let finalEffort = buttonEffort;
327
+ // Apply Board Change Processing Effort Discount (matching Ruby lines 347-350)
328
+ const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
329
+ if (btn.clone_id && boardPcts[btn.clone_id]) {
330
+ const discount = Math.min(changeEffort, (changeEffort * 0.3) / boardPcts[btn.clone_id]);
331
+ finalEffort -= discount;
332
+ }
333
+ else if (btn.semantic_id && boardPcts[btn.semantic_id]) {
334
+ const discount = Math.min(changeEffort, (changeEffort * 0.5) / boardPcts[btn.semantic_id]);
335
+ finalEffort -= discount;
336
+ }
337
+ const existing = knownButtons.get(btn.label);
338
+ const knownBtn = {
339
+ id: btn.id,
340
+ label: btn.label,
341
+ level,
342
+ effort: finalEffort,
343
+ count: (existing?.count || 0) + 1,
344
+ semantic_id: btn.semantic_id,
345
+ clone_id: btn.clone_id,
346
+ temporary_home_id: temporaryHomeId || undefined,
347
+ };
348
+ if (!existing || finalEffort < existing.effort) {
349
+ knownButtons.set(btn.label, knownBtn);
350
+ }
351
+ }
352
+ });
353
+ });
354
+ }
355
+ // Convert to array and group by level
356
+ const buttons = Array.from(knownButtons.values());
357
+ buttons.forEach((btn) => {
358
+ if (!levels[btn.level]) {
359
+ levels[btn.level] = [];
360
+ }
361
+ levels[btn.level].push(btn);
362
+ });
363
+ return {
364
+ buttons,
365
+ levels,
366
+ totalButtons: buttons.length,
367
+ };
368
+ }
369
+ /**
370
+ * Calculate what percentage of links to this board match semantic_id/clone_id
371
+ */
372
+ calculateBoardLinkPercentages(tree, board) {
373
+ const boardPcts = {};
374
+ let totalLinks = 0;
375
+ Object.values(tree.pages).forEach((sourceBoard) => {
376
+ sourceBoard.buttons.forEach((btn) => {
377
+ if (btn.targetPageId === board.id) {
378
+ totalLinks++;
379
+ if (btn.semantic_id) {
380
+ boardPcts[btn.semantic_id] = (boardPcts[btn.semantic_id] || 0) + 1;
381
+ }
382
+ if (btn.clone_id) {
383
+ boardPcts[btn.clone_id] = (boardPcts[btn.clone_id] || 0) + 1;
384
+ }
385
+ // Also count IDs present on the source board that links to this one
386
+ sourceBoard.semantic_ids?.forEach((id) => {
387
+ boardPcts[`upstream-${id}`] = (boardPcts[`upstream-${id}`] || 0) + 1;
388
+ });
389
+ sourceBoard.clone_ids?.forEach((id) => {
390
+ boardPcts[`upstream-${id}`] = (boardPcts[`upstream-${id}`] || 0) + 1;
391
+ });
392
+ }
393
+ });
394
+ });
395
+ // Convert counts to percentages
396
+ if (totalLinks > 0) {
397
+ Object.keys(boardPcts).forEach((id) => {
398
+ boardPcts[id] = boardPcts[id] / totalLinks;
399
+ });
400
+ }
401
+ boardPcts['all'] = totalLinks;
402
+ return boardPcts;
403
+ }
404
+ /**
405
+ * Calculate grid dimensions from the tree
406
+ */
407
+ calculateGridDimensions(tree) {
408
+ let totalRows = 0;
409
+ let totalCols = 0;
410
+ let count = 0;
411
+ Object.values(tree.pages).forEach((page) => {
412
+ totalRows += page.grid.length;
413
+ totalCols += page.grid[0]?.length || 0;
414
+ count++;
415
+ });
416
+ return {
417
+ rows: Math.round(totalRows / count),
418
+ columns: Math.round(totalCols / count),
419
+ };
420
+ }
421
+ }
422
+ exports.MetricsCalculator = MetricsCalculator;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Effort Score Calculation Algorithms
3
+ *
4
+ * Implements the core effort calculation algorithms from the Ruby aac-metrics tool.
5
+ * These algorithms calculate how difficult it is to access each button based on
6
+ * distance, visual scanning, grid complexity, and motor planning support.
7
+ */
8
+ /**
9
+ * Constants for effort score calculation
10
+ * Values match the Ruby implementation exactly
11
+ */
12
+ export declare const EFFORT_CONSTANTS: {
13
+ readonly SQRT2: number;
14
+ readonly BUTTON_SIZE_MULTIPLIER: 0.09;
15
+ readonly FIELD_SIZE_MULTIPLIER: 0.005;
16
+ readonly VISUAL_SCAN_MULTIPLIER: 0.015;
17
+ readonly BOARD_CHANGE_PROCESSING_EFFORT: 1;
18
+ readonly BOARD_HOME_EFFORT: 1;
19
+ readonly COMBINED_WORDS_REMEMBERING_EFFORT: 1;
20
+ readonly DISTANCE_MULTIPLIER: 0.4;
21
+ readonly DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN: 0.1;
22
+ readonly SKIPPED_VISUAL_SCAN_DISTANCE_MULTIPLIER: 0.5;
23
+ readonly SAME_LOCATION_AS_PRIOR_DISCOUNT: 0.1;
24
+ readonly RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT: 0.5;
25
+ readonly RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT: 0.5;
26
+ readonly REUSED_SEMANTIC_FROM_OTHER_BONUS: 0.0025;
27
+ readonly RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT: 0.33;
28
+ readonly RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT: 0.33;
29
+ readonly REUSED_CLONE_FROM_OTHER_BONUS: 0.005;
30
+ };
31
+ /**
32
+ * Calculate button size effort based on grid dimensions
33
+ * Larger grids require more visual scanning and discrimination
34
+ *
35
+ * @param rows - Number of rows in the grid
36
+ * @param cols - Number of columns in the grid
37
+ * @returns Button size effort score
38
+ */
39
+ export declare function buttonSizeEffort(rows: number, cols: number): number;
40
+ /**
41
+ * Calculate field size effort based on number of visible buttons
42
+ * More buttons = more visual clutter = higher effort
43
+ *
44
+ * @param buttonCount - Number of visible buttons on the board
45
+ * @returns Field size effort score
46
+ */
47
+ export declare function fieldSizeEffort(buttonCount: number): number;
48
+ /**
49
+ * Calculate visual scanning effort
50
+ * Effort increases with each button that must be scanned before reaching target
51
+ *
52
+ * @param priorButtons - Number of buttons visually scanned before target
53
+ * @returns Visual scan effort score
54
+ */
55
+ export declare function visualScanEffort(priorButtons: number): number;
56
+ /**
57
+ * Calculate distance effort from entry point to button center
58
+ * Uses Euclidean distance normalized by sqrt(2)
59
+ *
60
+ * @param x - Button center X coordinate (0-1 normalized)
61
+ * @param y - Button center Y coordinate (0-1 normalized)
62
+ * @param entryX - Entry point X coordinate (0-1 normalized, default 1.0 = bottom-right)
63
+ * @param entryY - Entry point Y coordinate (0-1 normalized, default 1.0 = bottom-right)
64
+ * @returns Distance effort score
65
+ */
66
+ export declare function distanceEffort(x: number, y: number, entryX?: number, entryY?: number): number;
67
+ /**
68
+ * Calculate spelling effort for words not available in the board set
69
+ * Base cost + per-letter cost
70
+ *
71
+ * @param word - The word to spell
72
+ * @returns Spelling effort score
73
+ */
74
+ export declare function spellingEffort(word: string): number;
75
+ /**
76
+ * Calculate base board effort
77
+ * Combines button size and field size efforts
78
+ *
79
+ * @param rows - Number of rows in the grid
80
+ * @param cols - Number of columns in the grid
81
+ * @param buttonCount - Number of visible buttons
82
+ * @returns Base board effort score
83
+ */
84
+ export declare function baseBoardEffort(rows: number, cols: number, buttonCount: number): number;
85
+ /**
86
+ * Apply reuse discount based on semantic_id/clone_id frequency
87
+ *
88
+ * @param boardEffort - Current board effort
89
+ * @param reuseDiscount - Calculated reuse discount
90
+ * @returns Adjusted board effort
91
+ */
92
+ export declare function applyReuseDiscount(boardEffort: number, reuseDiscount: number): number;
93
+ /**
94
+ * Calculate button-level effort with motor planning discounts
95
+ *
96
+ * @param baseEffort - Base board effort
97
+ * @param boardPcts - Percentage of links matching semantic_id/clone_id
98
+ * @param button - Button data
99
+ * @returns Adjusted button effort
100
+ */
101
+ export declare function calculateButtonEffort(baseEffort: number, boardPcts: {
102
+ [id: string]: number;
103
+ }, button: {
104
+ semantic_id?: string;
105
+ clone_id?: string;
106
+ }): number;
107
+ /**
108
+ * Calculate distance with motor planning discounts
109
+ *
110
+ * @param distance - Raw distance effort
111
+ * @param boardPcts - Percentage of links matching semantic_id/clone_id
112
+ * @param button - Button data
113
+ * @param setPcts - Percentage of boards containing semantic_id/clone_id
114
+ * @returns Adjusted distance effort
115
+ */
116
+ export declare function calculateDistanceWithDiscounts(distance: number, boardPcts: {
117
+ [id: string]: number;
118
+ }, button: {
119
+ semantic_id?: string;
120
+ clone_id?: string;
121
+ }, setPcts: {
122
+ [id: string]: number;
123
+ }): number;
124
+ /**
125
+ * Check if visual scan should be skipped (button close to previous)
126
+ *
127
+ * @param distance - Distance from previous button
128
+ * @returns True if close enough to skip full visual scan
129
+ */
130
+ export declare function shouldSkipVisualScan(distance: number): boolean;
131
+ /**
132
+ * Calculate local scan effort when buttons are close
133
+ *
134
+ * @param distance - Distance between buttons
135
+ * @returns Local scan effort
136
+ */
137
+ export declare function localScanEffort(distance: number): number;