@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.
- package/README.md +17 -8
- package/dist/core/treeStructure.d.ts +1 -0
- package/dist/processors/gridset/commands.js +15 -0
- package/dist/processors/gridset/pluginTypes.js +4 -4
- package/dist/processors/gridsetProcessor.d.ts +4 -0
- package/dist/processors/gridsetProcessor.js +315 -47
- package/dist/processors/snapProcessor.js +105 -9
- package/dist/processors/touchchatProcessor.js +33 -13
- package/dist/types/aac.d.ts +13 -3
- package/dist/types/aac.js +6 -2
- package/dist/utilities/analytics/metrics/comparison.d.ts +3 -1
- package/dist/utilities/analytics/metrics/comparison.js +189 -42
- package/dist/utilities/analytics/metrics/core.d.ts +7 -2
- package/dist/utilities/analytics/metrics/core.js +323 -197
- package/dist/utilities/analytics/metrics/effort.d.ts +23 -3
- package/dist/utilities/analytics/metrics/effort.js +31 -5
- package/dist/utilities/analytics/metrics/sentence.js +17 -4
- package/dist/utilities/analytics/metrics/types.d.ts +61 -0
- package/dist/utilities/analytics/metrics/vocabulary.js +1 -1
- package/dist/utilities/analytics/reference/index.d.ts +6 -0
- package/dist/utilities/analytics/reference/index.js +29 -1
- package/dist/utilities/translation/translationProcessor.d.ts +2 -1
- package/dist/utilities/translation/translationProcessor.js +5 -2
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
416
|
+
distance = Math.min(distance, distance * discount);
|
|
303
417
|
}
|
|
304
|
-
else if (
|
|
305
|
-
const discount = effort_1.EFFORT_CONSTANTS.
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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:
|
|
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 (
|
|
503
|
-
const order =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
525
|
-
const blockOrder =
|
|
526
|
-
//
|
|
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
|
|
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
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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 {
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
}
|