@willwade/aac-processors 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -41
- package/dist/cli/index.js +7 -0
- package/dist/core/analyze.js +1 -0
- package/dist/core/treeStructure.d.ts +45 -2
- package/dist/core/treeStructure.js +22 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.js +20 -3
- package/dist/{analytics → optional/analytics}/history.d.ts +15 -4
- package/dist/{analytics → optional/analytics}/history.js +3 -3
- package/dist/optional/analytics/index.d.ts +30 -0
- package/dist/optional/analytics/index.js +78 -0
- package/dist/optional/analytics/metrics/comparison.d.ts +36 -0
- package/dist/optional/analytics/metrics/comparison.js +334 -0
- package/dist/optional/analytics/metrics/core.d.ts +45 -0
- package/dist/optional/analytics/metrics/core.js +575 -0
- package/dist/optional/analytics/metrics/effort.d.ts +147 -0
- package/dist/optional/analytics/metrics/effort.js +211 -0
- package/dist/optional/analytics/metrics/index.d.ts +15 -0
- package/dist/optional/analytics/metrics/index.js +36 -0
- package/dist/optional/analytics/metrics/obl-types.d.ts +93 -0
- package/dist/optional/analytics/metrics/obl-types.js +7 -0
- package/dist/optional/analytics/metrics/obl.d.ts +40 -0
- package/dist/optional/analytics/metrics/obl.js +287 -0
- package/dist/optional/analytics/metrics/sentence.d.ts +49 -0
- package/dist/optional/analytics/metrics/sentence.js +112 -0
- package/dist/optional/analytics/metrics/types.d.ts +157 -0
- package/dist/optional/analytics/metrics/types.js +7 -0
- package/dist/optional/analytics/metrics/vocabulary.d.ts +65 -0
- package/dist/optional/analytics/metrics/vocabulary.js +142 -0
- package/dist/optional/analytics/reference/index.d.ts +51 -0
- package/dist/optional/analytics/reference/index.js +102 -0
- package/dist/optional/analytics/utils/idGenerator.d.ts +59 -0
- package/dist/optional/analytics/utils/idGenerator.js +96 -0
- package/dist/optional/symbolTools.js +13 -16
- package/dist/processors/astericsGridProcessor.d.ts +15 -0
- package/dist/processors/astericsGridProcessor.js +17 -0
- package/dist/processors/gridset/helpers.d.ts +4 -1
- package/dist/processors/gridset/helpers.js +4 -0
- package/dist/processors/gridset/pluginTypes.js +51 -50
- package/dist/processors/gridset/symbolExtractor.js +3 -2
- package/dist/processors/gridset/symbolSearch.js +9 -7
- package/dist/processors/gridsetProcessor.js +82 -20
- package/dist/processors/index.d.ts +1 -0
- package/dist/processors/index.js +5 -3
- package/dist/processors/obfProcessor.js +37 -2
- package/dist/processors/obfsetProcessor.d.ts +26 -0
- package/dist/processors/obfsetProcessor.js +179 -0
- package/dist/processors/snap/helpers.d.ts +5 -1
- package/dist/processors/snap/helpers.js +5 -0
- package/dist/processors/snapProcessor.d.ts +2 -0
- package/dist/processors/snapProcessor.js +184 -5
- package/dist/processors/touchchatProcessor.js +50 -4
- package/dist/types/aac.d.ts +67 -0
- package/dist/types/aac.js +33 -0
- package/dist/validation/gridsetValidator.js +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Core Metrics Analysis Engine
|
|
4
|
+
*
|
|
5
|
+
* Implements the main BFS traversal algorithm from the Ruby aac-metrics tool.
|
|
6
|
+
* Calculates effort scores for all buttons in an AAC board set.
|
|
7
|
+
*
|
|
8
|
+
* Based on: aac-metrics/lib/aac-metrics/metrics.rb
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.MetricsCalculator = void 0;
|
|
12
|
+
const treeStructure_1 = require("../../../core/treeStructure");
|
|
13
|
+
const aac_1 = require("../../../types/aac");
|
|
14
|
+
const effort_1 = require("./effort");
|
|
15
|
+
class MetricsCalculator {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.locale = 'en';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Main analysis function - calculates metrics for an AAC tree
|
|
21
|
+
*
|
|
22
|
+
* @param tree - The AAC tree to analyze
|
|
23
|
+
* @returns Complete metrics result
|
|
24
|
+
*/
|
|
25
|
+
analyze(tree) {
|
|
26
|
+
// Get root board - prioritize tree.rootId, then fall back to boards with no parentId
|
|
27
|
+
let rootBoard;
|
|
28
|
+
if (tree.rootId) {
|
|
29
|
+
rootBoard = tree.pages[tree.rootId];
|
|
30
|
+
}
|
|
31
|
+
if (!rootBoard) {
|
|
32
|
+
rootBoard = Object.values(tree.pages).find((p) => !p.parentId);
|
|
33
|
+
}
|
|
34
|
+
if (!rootBoard) {
|
|
35
|
+
throw new Error('No root board found in tree');
|
|
36
|
+
}
|
|
37
|
+
this.locale = rootBoard.locale || 'en';
|
|
38
|
+
// Step 1: Build semantic/clone reference maps
|
|
39
|
+
const { setRefs, setPcts } = this.buildReferenceMaps(tree);
|
|
40
|
+
// Step 2: BFS traversal from root board
|
|
41
|
+
const startBoards = [rootBoard];
|
|
42
|
+
// Find boards with temporary_home settings
|
|
43
|
+
Object.values(tree.pages).forEach((board) => {
|
|
44
|
+
board.buttons.forEach((btn) => {
|
|
45
|
+
if (btn.targetPageId && btn.semanticAction) {
|
|
46
|
+
// Check for temporary_home in platformData or fallback
|
|
47
|
+
const tempHome = btn.semanticAction.platformData?.grid3?.parameters?.temporary_home ||
|
|
48
|
+
btn.semanticAction.fallback?.temporary_home;
|
|
49
|
+
if (tempHome === 'prior') {
|
|
50
|
+
startBoards.push(board);
|
|
51
|
+
}
|
|
52
|
+
else if (tempHome === true && btn.targetPageId) {
|
|
53
|
+
const targetBoard = tree.getPage(btn.targetPageId);
|
|
54
|
+
if (targetBoard && !startBoards.includes(targetBoard)) {
|
|
55
|
+
startBoards.push(targetBoard);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
const knownButtons = new Map();
|
|
62
|
+
const levels = {};
|
|
63
|
+
let totalButtons = 0;
|
|
64
|
+
// Analyze from each starting board
|
|
65
|
+
startBoards.forEach((startBoard) => {
|
|
66
|
+
const result = this.analyzeFrom(tree, startBoard, setPcts, startBoard === rootBoard);
|
|
67
|
+
result.buttons.forEach((btn) => {
|
|
68
|
+
const existing = knownButtons.get(btn.label);
|
|
69
|
+
if (!existing || btn.effort < existing.effort) {
|
|
70
|
+
knownButtons.set(btn.label, btn);
|
|
71
|
+
}
|
|
72
|
+
if (btn.count && existing && existing.count) {
|
|
73
|
+
btn.count += existing.count;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
if (startBoard === rootBoard) {
|
|
77
|
+
Object.assign(levels, result.levels);
|
|
78
|
+
totalButtons = result.totalButtons;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
// Convert to array and sort
|
|
82
|
+
const buttons = Array.from(knownButtons.values()).sort((a, b) => a.effort - b.effort);
|
|
83
|
+
// Calculate grid dimensions
|
|
84
|
+
const grid = this.calculateGridDimensions(tree);
|
|
85
|
+
return {
|
|
86
|
+
analysis_version: '0.2',
|
|
87
|
+
locale: this.locale,
|
|
88
|
+
total_boards: Object.keys(tree.pages).length,
|
|
89
|
+
total_buttons: totalButtons,
|
|
90
|
+
total_words: buttons.length,
|
|
91
|
+
reference_counts: setRefs,
|
|
92
|
+
grid,
|
|
93
|
+
buttons,
|
|
94
|
+
levels,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Build reference maps for semantic_id and clone_id frequencies
|
|
99
|
+
*/
|
|
100
|
+
buildReferenceMaps(tree) {
|
|
101
|
+
const setRefs = {};
|
|
102
|
+
const cellRefs = {};
|
|
103
|
+
let rootRows = 0;
|
|
104
|
+
let rootCols = 0;
|
|
105
|
+
// First pass: calculate dimensions and count references
|
|
106
|
+
Object.values(tree.pages).forEach((board) => {
|
|
107
|
+
rootRows = rootRows || board.grid.length;
|
|
108
|
+
rootCols = rootCols || board.grid[0]?.length || 0;
|
|
109
|
+
// Count semantic_id and clone_id occurrences from board properties (upstream)
|
|
110
|
+
board.semantic_ids?.forEach((id) => {
|
|
111
|
+
setRefs[id] = (setRefs[id] || 0) + 1;
|
|
112
|
+
});
|
|
113
|
+
board.clone_ids?.forEach((id) => {
|
|
114
|
+
setRefs[id] = (setRefs[id] || 0) + 1;
|
|
115
|
+
});
|
|
116
|
+
// Count cell references
|
|
117
|
+
for (let r = 0; r < board.grid.length; r++) {
|
|
118
|
+
for (let c = 0; c < (board.grid[r]?.length || 0); c++) {
|
|
119
|
+
const ref = `${r}.${c}`;
|
|
120
|
+
const hasButton = board.grid[r][c] !== null;
|
|
121
|
+
cellRefs[ref] = (cellRefs[ref] || 0) + (hasButton ? 1.0 : 0.25);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
// Calculate percentages
|
|
126
|
+
const setPcts = {};
|
|
127
|
+
const totalBoards = Object.keys(tree.pages).length;
|
|
128
|
+
Object.entries(setRefs).forEach(([id, count]) => {
|
|
129
|
+
// Extract location from ID (Ruby uses id.split(/-/)[1])
|
|
130
|
+
const parts = id.split('-');
|
|
131
|
+
if (parts.length >= 2) {
|
|
132
|
+
const loc = parts[1];
|
|
133
|
+
const cellCount = cellRefs[loc] || totalBoards;
|
|
134
|
+
setPcts[id] = count / cellCount;
|
|
135
|
+
if (setPcts[id] > 1.0) {
|
|
136
|
+
// console.log(`⚠️ setPcts[${id}] = ${setPcts[id].toFixed(4)} (count=${count}, cellCount=${cellCount.toFixed(2)})`);
|
|
137
|
+
setPcts[id] = 1.0; // Cap at 1.0 like Ruby effectively does
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
setPcts[id] = count / totalBoards;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return { setRefs, setPcts };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Count scan items for visual scanning effort
|
|
148
|
+
* When block scanning is enabled, count unique scan blocks instead of individual buttons
|
|
149
|
+
*/
|
|
150
|
+
countScanItems(board, currentRowIndex, currentColIndex, priorScanBlocks, blockScanEnabled) {
|
|
151
|
+
if (!blockScanEnabled) {
|
|
152
|
+
// Linear scanning: count all buttons before current position
|
|
153
|
+
let count = 0;
|
|
154
|
+
for (let r = 0; r <= currentRowIndex; r++) {
|
|
155
|
+
const row = board.grid[r];
|
|
156
|
+
if (!row)
|
|
157
|
+
continue;
|
|
158
|
+
for (let c = 0; c < row.length; c++) {
|
|
159
|
+
if (r === currentRowIndex && c === currentColIndex)
|
|
160
|
+
return count;
|
|
161
|
+
const btn = row[c];
|
|
162
|
+
if (btn && (btn.label || btn.id).length > 0) {
|
|
163
|
+
count++;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return count;
|
|
168
|
+
}
|
|
169
|
+
// Block scanning: count unique scan blocks before current position
|
|
170
|
+
const seenBlocks = new Set();
|
|
171
|
+
for (let r = 0; r <= currentRowIndex; r++) {
|
|
172
|
+
const row = board.grid[r];
|
|
173
|
+
if (!row)
|
|
174
|
+
continue;
|
|
175
|
+
for (let c = 0; c < row.length; c++) {
|
|
176
|
+
if (r === currentRowIndex && c === currentColIndex)
|
|
177
|
+
return seenBlocks.size;
|
|
178
|
+
const btn = row[c];
|
|
179
|
+
if (btn && (btn.label || btn.id).length > 0) {
|
|
180
|
+
const block = btn.scanBlock ||
|
|
181
|
+
(btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null);
|
|
182
|
+
if (block !== null)
|
|
183
|
+
seenBlocks.add(block);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return seenBlocks.size;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Analyze starting from a specific board
|
|
191
|
+
*/
|
|
192
|
+
analyzeFrom(tree, brd, setPcts, _isRoot) {
|
|
193
|
+
const visitedBoardIds = new Map();
|
|
194
|
+
const toVisit = [
|
|
195
|
+
{
|
|
196
|
+
board: brd,
|
|
197
|
+
level: 0,
|
|
198
|
+
entryX: 1.0,
|
|
199
|
+
entryY: 1.0,
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
const knownButtons = new Map();
|
|
203
|
+
const levels = {};
|
|
204
|
+
while (toVisit.length > 0) {
|
|
205
|
+
const item = toVisit.shift();
|
|
206
|
+
if (!item)
|
|
207
|
+
break;
|
|
208
|
+
const { board, level, entryX, entryY, priorEffort = 0, temporaryHomeId } = item;
|
|
209
|
+
// Skip if already visited at a lower level with equal or better prior effort
|
|
210
|
+
// Skip if already visited at a strictly lower level
|
|
211
|
+
const existingLevel = visitedBoardIds.get(board.id);
|
|
212
|
+
if (existingLevel !== undefined && existingLevel < level) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
visitedBoardIds.set(board.id, level);
|
|
216
|
+
const rows = board.grid.length;
|
|
217
|
+
const cols = board.grid[0]?.length || 0;
|
|
218
|
+
// Calculate board-level effort
|
|
219
|
+
// Ruby uses grid size (rows * cols) for field size effort
|
|
220
|
+
const gridSize = rows * cols;
|
|
221
|
+
let boardEffort = (0, effort_1.baseBoardEffort)(rows, cols, gridSize);
|
|
222
|
+
// Apply reuse discounts - iterate through grid cells like Ruby does
|
|
223
|
+
let reuseDiscount = 0.0;
|
|
224
|
+
board.grid.forEach((row) => {
|
|
225
|
+
row.forEach((btn) => {
|
|
226
|
+
if (!btn)
|
|
227
|
+
return;
|
|
228
|
+
if (btn.clone_id && setPcts[btn.clone_id]) {
|
|
229
|
+
reuseDiscount += effort_1.EFFORT_CONSTANTS.REUSED_CLONE_FROM_OTHER_BONUS * setPcts[btn.clone_id];
|
|
230
|
+
}
|
|
231
|
+
else if (btn.semantic_id && setPcts[btn.semantic_id]) {
|
|
232
|
+
reuseDiscount +=
|
|
233
|
+
effort_1.EFFORT_CONSTANTS.REUSED_SEMANTIC_FROM_OTHER_BONUS * setPcts[btn.semantic_id];
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
boardEffort = Math.max(0, boardEffort - reuseDiscount);
|
|
238
|
+
// Calculate board link percentages
|
|
239
|
+
const boardPcts = this.calculateBoardLinkPercentages(tree, board);
|
|
240
|
+
// Get scanning configuration from page (if available)
|
|
241
|
+
const blockScanEnabled = board.scanningConfig?.blockScanEnabled || false;
|
|
242
|
+
// Process each button
|
|
243
|
+
const priorScanBlocks = new Set();
|
|
244
|
+
const btnHeight = 1.0 / rows;
|
|
245
|
+
const btnWidth = 1.0 / cols;
|
|
246
|
+
board.grid.forEach((row, rowIndex) => {
|
|
247
|
+
row.forEach((btn, colIndex) => {
|
|
248
|
+
// Skip null buttons and buttons with empty labels (matching Ruby behavior)
|
|
249
|
+
if (!btn || (btn.label || '').length === 0) {
|
|
250
|
+
// Don't count these toward prior_buttons (Ruby uses "next unless")
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const x = btnWidth / 2 + btnWidth * colIndex;
|
|
254
|
+
const y = btnHeight / 2 + btnHeight * rowIndex;
|
|
255
|
+
// Calculate prior items for visual scan effort
|
|
256
|
+
// If block scanning enabled, count unique scan blocks instead of individual buttons
|
|
257
|
+
const priorItems = this.countScanItems(board, rowIndex, colIndex, priorScanBlocks, blockScanEnabled);
|
|
258
|
+
// Calculate button-level effort
|
|
259
|
+
let buttonEffort = boardEffort;
|
|
260
|
+
// Debug for specific button (disabled for production)
|
|
261
|
+
const debugSpecificButton = btn.label === '$938c2cc0dc';
|
|
262
|
+
if (debugSpecificButton) {
|
|
263
|
+
console.log(`\n🔍 DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:`);
|
|
264
|
+
console.log(` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`);
|
|
265
|
+
console.log(` Current level: ${level}`);
|
|
266
|
+
console.log(` Starting effort: ${buttonEffort.toFixed(6)}`);
|
|
267
|
+
}
|
|
268
|
+
// Apply semantic_id discounts
|
|
269
|
+
if (btn.semantic_id && boardPcts[btn.semantic_id]) {
|
|
270
|
+
const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
|
|
271
|
+
const old = buttonEffort;
|
|
272
|
+
buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
|
|
273
|
+
if (debugSpecificButton)
|
|
274
|
+
console.log(` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})`);
|
|
275
|
+
}
|
|
276
|
+
else if (btn.semantic_id && boardPcts[`upstream-${btn.semantic_id}`]) {
|
|
277
|
+
const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
|
|
278
|
+
boardPcts[`upstream-${btn.semantic_id}`];
|
|
279
|
+
const old = buttonEffort;
|
|
280
|
+
buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
|
|
281
|
+
if (debugSpecificButton)
|
|
282
|
+
console.log(` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})`);
|
|
283
|
+
}
|
|
284
|
+
// Apply clone_id discounts
|
|
285
|
+
if (btn.clone_id && boardPcts[btn.clone_id]) {
|
|
286
|
+
const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
|
|
287
|
+
buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
|
|
288
|
+
}
|
|
289
|
+
else if (btn.clone_id && boardPcts[`upstream-${btn.clone_id}`]) {
|
|
290
|
+
const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
|
|
291
|
+
boardPcts[`upstream-${btn.clone_id}`];
|
|
292
|
+
buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
|
|
293
|
+
}
|
|
294
|
+
// Calculate button effort based on access method (Touch vs Scanning)
|
|
295
|
+
const isScanning = !!board.scanningConfig || !!board.scanType;
|
|
296
|
+
if (isScanning) {
|
|
297
|
+
const { steps, selections } = this.calculateScanSteps(board, btn, rowIndex, colIndex);
|
|
298
|
+
let sEffort = (0, effort_1.scanningEffort)(steps, selections);
|
|
299
|
+
// Apply discounts to scanning effort (similar to touch)
|
|
300
|
+
if (btn.semantic_id && boardPcts[btn.semantic_id]) {
|
|
301
|
+
const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
|
|
302
|
+
sEffort = Math.min(sEffort, sEffort * discount);
|
|
303
|
+
}
|
|
304
|
+
else if (btn.clone_id && boardPcts[btn.clone_id]) {
|
|
305
|
+
const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
|
|
306
|
+
sEffort = Math.min(sEffort, sEffort * discount);
|
|
307
|
+
}
|
|
308
|
+
buttonEffort += sEffort;
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
// Add distance effort (Touch only)
|
|
312
|
+
let distance = (0, effort_1.distanceEffort)(x, y, entryX, entryY);
|
|
313
|
+
// Apply distance discounts
|
|
314
|
+
if (btn.semantic_id) {
|
|
315
|
+
if (boardPcts[btn.semantic_id]) {
|
|
316
|
+
const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
|
|
317
|
+
distance = Math.min(distance, distance * discount);
|
|
318
|
+
}
|
|
319
|
+
else if (boardPcts[`upstream-${btn.semantic_id}`]) {
|
|
320
|
+
const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
|
|
321
|
+
boardPcts[`upstream-${btn.semantic_id}`];
|
|
322
|
+
distance = Math.min(distance, distance * discount);
|
|
323
|
+
}
|
|
324
|
+
else if (level > 0 && setPcts[btn.semantic_id]) {
|
|
325
|
+
const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT /
|
|
326
|
+
setPcts[btn.semantic_id];
|
|
327
|
+
distance = Math.min(distance, distance * discount);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (btn.clone_id) {
|
|
331
|
+
if (boardPcts[btn.clone_id]) {
|
|
332
|
+
const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
|
|
333
|
+
distance = Math.min(distance, distance * discount);
|
|
334
|
+
}
|
|
335
|
+
else if (boardPcts[`upstream-${btn.clone_id}`]) {
|
|
336
|
+
const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
|
|
337
|
+
boardPcts[`upstream-${btn.clone_id}`];
|
|
338
|
+
distance = Math.min(distance, distance * discount);
|
|
339
|
+
}
|
|
340
|
+
else if (level > 0 && setPcts[btn.clone_id]) {
|
|
341
|
+
const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[btn.clone_id];
|
|
342
|
+
distance = Math.min(distance, distance * discount);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
buttonEffort += distance;
|
|
346
|
+
// Add visual scan or local scan effort
|
|
347
|
+
if (distance > effort_1.EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN ||
|
|
348
|
+
(entryX === 1.0 && entryY === 1.0)) {
|
|
349
|
+
buttonEffort += (0, effort_1.visualScanEffort)(priorItems);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
buttonEffort += (0, effort_1.localScanEffort)(distance);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// Add cumulative prior effort
|
|
356
|
+
buttonEffort += priorEffort;
|
|
357
|
+
// Track scan blocks for block scanning, otherwise track individual buttons
|
|
358
|
+
if (blockScanEnabled) {
|
|
359
|
+
const scanBlockId = btn.scanBlock ?? btn.scanBlocks?.[0];
|
|
360
|
+
if (scanBlockId !== undefined && scanBlockId !== null) {
|
|
361
|
+
priorScanBlocks.add(scanBlockId);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Handle navigation buttons
|
|
365
|
+
if (btn.targetPageId) {
|
|
366
|
+
const nextBoard = tree.getPage(btn.targetPageId);
|
|
367
|
+
if (nextBoard) {
|
|
368
|
+
// Only add to toVisit if this board hasn't been visited yet at any level
|
|
369
|
+
// The visitedBoardIds map stores the *lowest* level a board was visited.
|
|
370
|
+
// If it's already in the map, it means we've processed it or scheduled it at a lower level.
|
|
371
|
+
if (visitedBoardIds.get(nextBoard.id) === undefined) {
|
|
372
|
+
const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
|
|
373
|
+
const tempHomeId = btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === 'prior'
|
|
374
|
+
? board.id
|
|
375
|
+
: btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === true
|
|
376
|
+
? btn.targetPageId
|
|
377
|
+
: temporaryHomeId;
|
|
378
|
+
toVisit.push({
|
|
379
|
+
board: nextBoard,
|
|
380
|
+
level: level + 1,
|
|
381
|
+
priorEffort: buttonEffort + changeEffort,
|
|
382
|
+
temporaryHomeId: tempHomeId,
|
|
383
|
+
entryX: x,
|
|
384
|
+
entryY: y,
|
|
385
|
+
entryCloneId: btn.clone_id,
|
|
386
|
+
entrySemanticId: btn.semantic_id,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// Track word if it speaks or adds to sentence
|
|
392
|
+
const intent = String(btn.semanticAction?.intent || '');
|
|
393
|
+
const isSpeak = btn.semanticAction?.category === treeStructure_1.AACSemanticCategory.COMMUNICATION ||
|
|
394
|
+
intent === 'SPEAK_TEXT' ||
|
|
395
|
+
intent === 'SPEAK_IMMEDIATE' ||
|
|
396
|
+
intent === 'INSERT_TEXT' ||
|
|
397
|
+
btn.semanticAction?.fallback?.type === 'SPEAK';
|
|
398
|
+
const addToSentence = btn.semanticAction?.platformData?.grid3?.parameters?.add_to_sentence ||
|
|
399
|
+
btn.semanticAction?.fallback?.add_to_sentence;
|
|
400
|
+
if (isSpeak || addToSentence) {
|
|
401
|
+
let finalEffort = buttonEffort;
|
|
402
|
+
// Apply Board Change Processing Effort Discount (matching Ruby lines 347-350)
|
|
403
|
+
const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
|
|
404
|
+
if (btn.clone_id && boardPcts[btn.clone_id]) {
|
|
405
|
+
const discount = Math.min(changeEffort, (changeEffort * 0.3) / boardPcts[btn.clone_id]);
|
|
406
|
+
finalEffort -= discount;
|
|
407
|
+
}
|
|
408
|
+
else if (btn.semantic_id && boardPcts[btn.semantic_id]) {
|
|
409
|
+
const discount = Math.min(changeEffort, (changeEffort * 0.5) / boardPcts[btn.semantic_id]);
|
|
410
|
+
finalEffort -= discount;
|
|
411
|
+
}
|
|
412
|
+
const existing = knownButtons.get(btn.label);
|
|
413
|
+
const knownBtn = {
|
|
414
|
+
id: btn.id,
|
|
415
|
+
label: btn.label,
|
|
416
|
+
level,
|
|
417
|
+
effort: finalEffort,
|
|
418
|
+
count: (existing?.count || 0) + 1,
|
|
419
|
+
semantic_id: btn.semantic_id,
|
|
420
|
+
clone_id: btn.clone_id,
|
|
421
|
+
temporary_home_id: temporaryHomeId || undefined,
|
|
422
|
+
};
|
|
423
|
+
if (!existing || finalEffort < existing.effort) {
|
|
424
|
+
knownButtons.set(btn.label, knownBtn);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
// Convert to array and group by level
|
|
431
|
+
const buttons = Array.from(knownButtons.values());
|
|
432
|
+
buttons.forEach((btn) => {
|
|
433
|
+
if (!levels[btn.level]) {
|
|
434
|
+
levels[btn.level] = [];
|
|
435
|
+
}
|
|
436
|
+
levels[btn.level].push(btn);
|
|
437
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
buttons,
|
|
440
|
+
levels,
|
|
441
|
+
totalButtons: buttons.length,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Calculate what percentage of links to this board match semantic_id/clone_id
|
|
446
|
+
*/
|
|
447
|
+
calculateBoardLinkPercentages(tree, board) {
|
|
448
|
+
const boardPcts = {};
|
|
449
|
+
let totalLinks = 0;
|
|
450
|
+
Object.values(tree.pages).forEach((sourceBoard) => {
|
|
451
|
+
sourceBoard.buttons.forEach((btn) => {
|
|
452
|
+
if (btn.targetPageId === board.id) {
|
|
453
|
+
totalLinks++;
|
|
454
|
+
if (btn.semantic_id) {
|
|
455
|
+
boardPcts[btn.semantic_id] = (boardPcts[btn.semantic_id] || 0) + 1;
|
|
456
|
+
}
|
|
457
|
+
if (btn.clone_id) {
|
|
458
|
+
boardPcts[btn.clone_id] = (boardPcts[btn.clone_id] || 0) + 1;
|
|
459
|
+
}
|
|
460
|
+
// Also count IDs present on the source board that links to this one
|
|
461
|
+
sourceBoard.semantic_ids?.forEach((id) => {
|
|
462
|
+
boardPcts[`upstream-${id}`] = (boardPcts[`upstream-${id}`] || 0) + 1;
|
|
463
|
+
});
|
|
464
|
+
sourceBoard.clone_ids?.forEach((id) => {
|
|
465
|
+
boardPcts[`upstream-${id}`] = (boardPcts[`upstream-${id}`] || 0) + 1;
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
// Convert counts to percentages
|
|
471
|
+
if (totalLinks > 0) {
|
|
472
|
+
Object.keys(boardPcts).forEach((id) => {
|
|
473
|
+
boardPcts[id] = boardPcts[id] / totalLinks;
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
boardPcts['all'] = totalLinks;
|
|
477
|
+
return boardPcts;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Calculate grid dimensions from the tree
|
|
481
|
+
*/
|
|
482
|
+
calculateGridDimensions(tree) {
|
|
483
|
+
let totalRows = 0;
|
|
484
|
+
let totalCols = 0;
|
|
485
|
+
let count = 0;
|
|
486
|
+
Object.values(tree.pages).forEach((page) => {
|
|
487
|
+
totalRows += page.grid.length;
|
|
488
|
+
totalCols += page.grid[0]?.length || 0;
|
|
489
|
+
count++;
|
|
490
|
+
});
|
|
491
|
+
return {
|
|
492
|
+
rows: Math.round(totalRows / count),
|
|
493
|
+
columns: Math.round(totalCols / count),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Calculate scanning steps and selections for a button based on access method
|
|
498
|
+
*/
|
|
499
|
+
calculateScanSteps(board, btn, rowIndex, colIndex) {
|
|
500
|
+
// Determine scanning type from local scanType or scanningConfig
|
|
501
|
+
let type = board.scanType || treeStructure_1.AACScanType.LINEAR;
|
|
502
|
+
if (board.scanningConfig?.cellScanningOrder) {
|
|
503
|
+
const order = board.scanningConfig.cellScanningOrder;
|
|
504
|
+
// String matching for CellScanningOrder
|
|
505
|
+
if (order === aac_1.CellScanningOrder.RowColumnScan)
|
|
506
|
+
type = treeStructure_1.AACScanType.ROW_COLUMN;
|
|
507
|
+
else if (order === aac_1.CellScanningOrder.ColumnRowScan)
|
|
508
|
+
type = treeStructure_1.AACScanType.COLUMN_ROW;
|
|
509
|
+
else if (order === aac_1.CellScanningOrder.SimpleScanColumnsFirst)
|
|
510
|
+
type = treeStructure_1.AACScanType.COLUMN_ROW;
|
|
511
|
+
else if (order === aac_1.CellScanningOrder.SimpleScan)
|
|
512
|
+
type = treeStructure_1.AACScanType.LINEAR;
|
|
513
|
+
}
|
|
514
|
+
// Force block scan if enabled in config
|
|
515
|
+
const isBlockScan = board.scanningConfig?.blockScanEnabled ||
|
|
516
|
+
type === treeStructure_1.AACScanType.BLOCK_ROW_COLUMN ||
|
|
517
|
+
type === treeStructure_1.AACScanType.BLOCK_COLUMN_ROW;
|
|
518
|
+
if (isBlockScan) {
|
|
519
|
+
const blockId = btn.scanBlock || (btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null);
|
|
520
|
+
// If no block assigned, treat as its own block at the end (fallback)
|
|
521
|
+
if (blockId === null) {
|
|
522
|
+
return { steps: rowIndex + colIndex + 1, selections: 1 };
|
|
523
|
+
}
|
|
524
|
+
const config = board.scanBlocksConfig?.find((c) => c.id === blockId);
|
|
525
|
+
const blockOrder = config?.order ?? blockId;
|
|
526
|
+
// Linear scan within the block
|
|
527
|
+
let btnInBlockIndex = 0;
|
|
528
|
+
let found = false;
|
|
529
|
+
for (let r = 0; r < board.grid.length; r++) {
|
|
530
|
+
for (let c = 0; c < (board.grid[r]?.length || 0); c++) {
|
|
531
|
+
const b = board.grid[r][c];
|
|
532
|
+
if (b && (b.scanBlock === blockId || b.scanBlocks?.includes(blockId))) {
|
|
533
|
+
if (b === btn) {
|
|
534
|
+
found = true;
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
btnInBlockIndex++;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (found)
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
// 1 selection for block, 1 for item
|
|
544
|
+
return { steps: blockOrder + btnInBlockIndex + 1, selections: 2 };
|
|
545
|
+
}
|
|
546
|
+
switch (type) {
|
|
547
|
+
case treeStructure_1.AACScanType.LINEAR: {
|
|
548
|
+
let index = 0;
|
|
549
|
+
let found = false;
|
|
550
|
+
for (let r = 0; r < board.grid.length; r++) {
|
|
551
|
+
for (let c = 0; c < board.grid[r].length; c++) {
|
|
552
|
+
const b = board.grid[r][c];
|
|
553
|
+
if (b && (b.label || '').length > 0) {
|
|
554
|
+
if (b === btn) {
|
|
555
|
+
found = true;
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
index++;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (found)
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
return { steps: index + 1, selections: 1 };
|
|
565
|
+
}
|
|
566
|
+
case treeStructure_1.AACScanType.ROW_COLUMN:
|
|
567
|
+
return { steps: rowIndex + 1 + (colIndex + 1), selections: 2 };
|
|
568
|
+
case treeStructure_1.AACScanType.COLUMN_ROW:
|
|
569
|
+
return { steps: colIndex + 1 + (rowIndex + 1), selections: 2 };
|
|
570
|
+
default:
|
|
571
|
+
return { steps: rowIndex + 1 + (colIndex + 1), selections: 2 };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
exports.MetricsCalculator = MetricsCalculator;
|