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