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