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