@willwade/aac-processors 0.0.21 → 0.0.22

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.
@@ -1,5 +1,5 @@
1
- import { AACButton as IAACButton, AACPage as IAACPage, AACTree as IAACTree, AACTreeMetadata, SnapMetadata, GridSetMetadata, AstericsGridMetadata, TouchChatMetadata, AACStyle } from '../types/aac';
2
- export { AACTreeMetadata, SnapMetadata, GridSetMetadata, AstericsGridMetadata, TouchChatMetadata };
1
+ import { AACButton as IAACButton, AACPage as IAACPage, AACTree as IAACTree, AACTreeMetadata, SnapMetadata, GridSetMetadata, AstericsGridMetadata, TouchChatMetadata, AACStyle, CellScanningOrder, ScanningSelectionMethod } from '../types/aac';
2
+ export { AACTreeMetadata, SnapMetadata, GridSetMetadata, AstericsGridMetadata, TouchChatMetadata, CellScanningOrder, ScanningSelectionMethod, };
3
3
  export declare enum AACSemanticCategory {
4
4
  COMMUNICATION = "communication",// Speech, text output
5
5
  NAVIGATION = "navigation",// Page/grid navigation
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.AACTree = exports.AACPage = exports.AACButton = exports.AACScanType = exports.AACSemanticIntent = exports.AACSemanticCategory = void 0;
3
+ exports.AACTree = exports.AACPage = exports.AACButton = exports.AACScanType = exports.AACSemanticIntent = exports.AACSemanticCategory = exports.ScanningSelectionMethod = exports.CellScanningOrder = void 0;
4
+ const aac_1 = require("../types/aac");
5
+ Object.defineProperty(exports, "CellScanningOrder", { enumerable: true, get: function () { return aac_1.CellScanningOrder; } });
6
+ Object.defineProperty(exports, "ScanningSelectionMethod", { enumerable: true, get: function () { return aac_1.ScanningSelectionMethod; } });
4
7
  // Semantic action categories for cross-platform compatibility
5
8
  var AACSemanticCategory;
6
9
  (function (AACSemanticCategory) {
@@ -1400,6 +1400,8 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1400
1400
  const homeGridId = gridNameToIdMap.get(startGridName);
1401
1401
  if (homeGridId) {
1402
1402
  metadata.defaultHomePageId = homeGridId;
1403
+ // Also set tree.rootId so BoardViewer knows which page to show first
1404
+ tree.rootId = homeGridId;
1403
1405
  }
1404
1406
  }
1405
1407
  const keyboardGridName = settingsData?.GridSetSettings?.KeyboardGrid ||
@@ -29,6 +29,12 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
29
29
  }
30
30
  processBoard(boardData, _boardPath) {
31
31
  const sourceButtons = boardData.buttons || [];
32
+ // Calculate page ID first (used to make button IDs unique)
33
+ const pageId = _boardPath && _boardPath.endsWith('.obf') && !_boardPath.includes('/')
34
+ ? _boardPath // Zip entry - use filename to match navigation paths
35
+ : boardData?.id
36
+ ? String(boardData.id)
37
+ : _boardPath?.split('/').pop() || '';
32
38
  const buttons = sourceButtons.map((btn) => {
33
39
  const semanticAction = btn.load_board
34
40
  ? {
@@ -50,7 +56,8 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
50
56
  },
51
57
  };
52
58
  return new treeStructure_1.AACButton({
53
- id: String(btn?.id || ''),
59
+ // Make button ID unique by combining page ID and button ID
60
+ id: `${pageId}::${btn?.id || ''}`,
54
61
  label: String(btn?.label || ''),
55
62
  message: String(btn?.vocalization || btn?.label || ''),
56
63
  visibility: mapObfVisibility(btn.hidden),
@@ -65,7 +72,7 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
65
72
  });
66
73
  const buttonMap = new Map(buttons.map((btn) => [btn.id, btn]));
67
74
  const page = new treeStructure_1.AACPage({
68
- id: String(boardData?.id || ''),
75
+ id: pageId, // Use the page ID we calculated earlier
69
76
  name: String(boardData?.name || ''),
70
77
  grid: [],
71
78
  buttons,
@@ -96,7 +103,7 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
96
103
  return;
97
104
  if (rowIndex >= rows || colIndex >= cols)
98
105
  return;
99
- const aacBtn = buttonMap.get(String(cellId));
106
+ const aacBtn = buttonMap.get(`${pageId}::${cellId}`);
100
107
  if (aacBtn) {
101
108
  grid[rowIndex][colIndex] = aacBtn;
102
109
  }
@@ -109,7 +116,7 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
109
116
  const row = Math.floor(btn.box_id / cols);
110
117
  const col = btn.box_id % cols;
111
118
  if (row < rows && col < cols) {
112
- const aacBtn = buttonMap.get(String(btn.id));
119
+ const aacBtn = buttonMap.get(`${pageId}::${btn.id}`);
113
120
  if (aacBtn) {
114
121
  grid[row][col] = aacBtn;
115
122
  }
@@ -104,7 +104,8 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
104
104
  // Set toolbarId if there's a global toolbar
105
105
  if (hasGlobalToolbar) {
106
106
  tree.toolbarId = toolbarId || null;
107
- tree.rootId = toolbarId || defaultHomePageId || null;
107
+ // Use defaultHomePageId as root (the content pageset), not the toolbar
108
+ tree.rootId = defaultHomePageId || null;
108
109
  }
109
110
  else if (defaultHomePageId) {
110
111
  tree.rootId = defaultHomePageId;
@@ -114,14 +115,7 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
114
115
  catch (e) {
115
116
  console.warn('[SnapProcessor] Failed to load PageSetProperties:', e);
116
117
  }
117
- // If still no root, look for a page titled "Tool Bar" or similar
118
- if (!tree.rootId || tree.rootId === defaultHomePageId) {
119
- const toolbarPage = pages.find((p) => p.Title === 'Tool Bar' || p.Name === 'Tool Bar');
120
- if (toolbarPage) {
121
- tree.rootId = String(toolbarPage.UniqueId || toolbarPage.Id);
122
- }
123
- }
124
- // If still no root, fallback to first page
118
+ // If still no root, fallback to first page (but don't override a valid defaultHomePageId)
125
119
  if (!tree.rootId && pages.length > 0) {
126
120
  tree.rootId = String(pages[0].UniqueId || pages[0].Id);
127
121
  }
@@ -144,6 +138,22 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
144
138
  });
145
139
  tree.addPage(page);
146
140
  });
141
+ // Try to find toolbar page even if not set in PageSetProperties
142
+ // Some SNAP files have a toolbar page but don't set ToolBarUniqueId
143
+ // This must be done AFTER pages are added to the tree
144
+ if (!tree.toolbarId || tree.toolbarId === '00000000-0000-0000-0000-000000000000') {
145
+ const toolbarPage = Object.values(tree.pages).find((p) => {
146
+ const name = (p.name || '').toLowerCase();
147
+ return name === 'tool bar' || name === 'toolbar';
148
+ });
149
+ if (toolbarPage) {
150
+ tree.toolbarId = toolbarPage.id;
151
+ // Update metadata to reflect toolbar detection
152
+ if (tree.metadata) {
153
+ tree.metadata.hasGlobalToolbar = true;
154
+ }
155
+ }
156
+ }
147
157
  const scanGroupsByPageLayout = new Map();
148
158
  try {
149
159
  const scanGroupRows = db
@@ -159,7 +159,7 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
159
159
  });
160
160
  // Load button boxes and their cells
161
161
  const buttonBoxQuery = `
162
- SELECT bbc.*, b.*, bb.id as box_id
162
+ SELECT bbc.*, b.*, bb.id as box_id, bb.layout_x, bb.layout_y
163
163
  FROM button_box_cells bbc
164
164
  JOIN buttons b ON b.resource_id = bbc.resource_id
165
165
  JOIN button_boxes bb ON bb.id = bbc.button_box_id
@@ -168,8 +168,14 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
168
168
  const buttonBoxCells = db.prepare(buttonBoxQuery).all();
169
169
  const buttonBoxes = new Map();
170
170
  buttonBoxCells.forEach((cell) => {
171
- if (!buttonBoxes.has(cell.box_id)) {
172
- buttonBoxes.set(cell.box_id, []);
171
+ let boxData = buttonBoxes.get(cell.box_id);
172
+ if (!boxData) {
173
+ boxData = {
174
+ layoutX: cell.layout_x || 10,
175
+ layoutY: cell.layout_y || 6,
176
+ buttons: [],
177
+ };
178
+ buttonBoxes.set(cell.box_id, boxData);
173
179
  }
174
180
  const style = buttonStyles.get(cell.button_style_id);
175
181
  // Create semantic action for TouchChat button
@@ -213,7 +219,7 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
213
219
  labelOnTop: toBooleanOrUndefined(style?.label_on_top),
214
220
  },
215
221
  });
216
- buttonBoxes.get(cell.box_id)?.push({
222
+ boxData.buttons.push({
217
223
  button,
218
224
  location: cell.location || 0,
219
225
  spanX: cell.span_x || 1,
@@ -228,8 +234,8 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
228
234
  // Use mapped string ID if available, otherwise use numeric ID as string
229
235
  const pageId = numericToRid.get(instance.page_id) || String(instance.page_id);
230
236
  const page = tree.getPage(pageId);
231
- const buttons = buttonBoxes.get(instance.button_box_id);
232
- if (page && buttons) {
237
+ const boxData = buttonBoxes.get(instance.button_box_id);
238
+ if (page && boxData) {
233
239
  // Initialize page grid if not exists (assume max 10x10 grid)
234
240
  if (!pageGrids.has(pageId)) {
235
241
  const grid = [];
@@ -243,15 +249,13 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
243
249
  return;
244
250
  const boxX = Number(instance.position_x) || 0;
245
251
  const boxY = Number(instance.position_y) || 0;
246
- const boxWidth = Number(instance.size_x) || 1;
252
+ const boxWidth = boxData.layoutX; // Use layout_x from button_boxes, not size_x from instance
247
253
  // boxHeight not currently used but kept for future span calculations
248
- // const boxHeight = Number(instance.size_y) || 1;
249
- buttons.forEach(({ button, location, spanX, spanY }) => {
254
+ // const boxHeight = boxData.layoutY;
255
+ boxData.buttons.forEach(({ button, location, spanX, spanY }) => {
250
256
  const safeLocation = Number(location) || 0;
251
257
  const safeSpanX = Number(spanX) || 1;
252
258
  const safeSpanY = Number(spanY) || 1;
253
- // Add button to page
254
- page.addButton(button);
255
259
  // Calculate button position within the button box
256
260
  // location is a linear index, convert to grid coordinates
257
261
  const buttonX = safeLocation % boxWidth;
@@ -259,6 +263,11 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
259
263
  // Calculate absolute position on page
260
264
  const absoluteX = boxX + buttonX;
261
265
  const absoluteY = boxY + buttonY;
266
+ // Set button's x and y coordinates
267
+ button.x = absoluteX;
268
+ button.y = absoluteY;
269
+ // Add button to page
270
+ page.addButton(button);
262
271
  // Place button in grid (handle span)
263
272
  for (let r = absoluteY; r < absoluteY + safeSpanY && r < 10; r++) {
264
273
  for (let c = absoluteX; c < absoluteX + safeSpanX && c < 10; c++) {
@@ -412,21 +421,49 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
412
421
  catch (e) {
413
422
  // console.log('No navigation actions found:', e);
414
423
  }
415
- // Try to load root ID from metadata, fallback to first page
424
+ // Try to load root ID from multiple sources in order of priority
416
425
  try {
417
- const metadataQuery = "SELECT value FROM tree_metadata WHERE key = 'rootId'";
418
- const rootIdRow = db.prepare(metadataQuery).get();
419
- if (rootIdRow && tree.getPage(rootIdRow.value)) {
420
- tree.rootId = rootIdRow.value;
426
+ // First, try to get HOME page from special_pages table (TouchChat specific)
427
+ const specialPagesQuery = "SELECT page_id FROM special_pages WHERE name = 'HOME'";
428
+ const homePageRow = db.prepare(specialPagesQuery).get();
429
+ if (homePageRow) {
430
+ // The page_id is the page's id (not resource_id), need to get the RID
431
+ const homePageIdQuery = `
432
+ SELECT p.id, r.rid
433
+ FROM pages p
434
+ JOIN resources r ON r.id = p.resource_id
435
+ WHERE p.id = ?
436
+ LIMIT 1
437
+ `;
438
+ const homePage = db.prepare(homePageIdQuery).get(homePageRow.page_id);
439
+ if (homePage) {
440
+ const homePageUUID = homePage.rid || String(homePage.id);
441
+ if (tree.getPage(homePageUUID)) {
442
+ tree.rootId = homePageUUID;
443
+ tree.metadata.defaultHomePageId = homePageUUID;
444
+ }
445
+ }
446
+ }
447
+ // If no HOME page found, try tree_metadata table (general fallback)
448
+ if (!tree.rootId) {
449
+ const metadataQuery = "SELECT value FROM tree_metadata WHERE key = 'rootId'";
450
+ const rootIdRow = db.prepare(metadataQuery).get();
451
+ if (rootIdRow && tree.getPage(rootIdRow.value)) {
452
+ tree.rootId = rootIdRow.value;
453
+ tree.metadata.defaultHomePageId = rootIdRow.value;
454
+ }
421
455
  }
422
- else if (rootPageId) {
456
+ // Final fallback: first page
457
+ if (!tree.rootId && rootPageId) {
423
458
  tree.rootId = rootPageId;
459
+ tree.metadata.defaultHomePageId = rootPageId;
424
460
  }
425
461
  }
426
462
  catch (e) {
427
- // No metadata table, use first page as root
463
+ // No metadata table or other error, use first page as root
428
464
  if (rootPageId) {
429
465
  tree.rootId = rootPageId;
466
+ tree.metadata.defaultHomePageId = rootPageId;
430
467
  }
431
468
  }
432
469
  // Set metadata for TouchChat files
@@ -519,7 +556,14 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
519
556
  );
520
557
 
521
558
  CREATE TABLE IF NOT EXISTS button_boxes (
522
- id INTEGER PRIMARY KEY
559
+ id INTEGER PRIMARY KEY,
560
+ resource_id INTEGER,
561
+ layout_x INTEGER DEFAULT 10,
562
+ layout_y INTEGER DEFAULT 6,
563
+ init_size_x INTEGER DEFAULT 10000,
564
+ init_size_y INTEGER DEFAULT 10000,
565
+ scan_pattern_id INTEGER DEFAULT 0,
566
+ FOREIGN KEY (resource_id) REFERENCES resources (id)
523
567
  );
524
568
 
525
569
  CREATE TABLE IF NOT EXISTS button_box_cells (
@@ -677,8 +721,15 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
677
721
  }
678
722
  // Create a button box for this page's buttons
679
723
  const buttonBoxId = buttonBoxIdCounter++;
680
- const insertButtonBox = db.prepare('INSERT INTO button_boxes (id) VALUES (?)');
681
- insertButtonBox.run(buttonBoxId);
724
+ // Create a resource for the button box
725
+ const buttonBoxResourceId = resourceIdCounter++;
726
+ const insertButtonBoxResource = db.prepare('INSERT INTO resources (id, name, type) VALUES (?, ?, ?)');
727
+ insertButtonBoxResource.run(buttonBoxResourceId, page.name || 'ButtonBox', 0);
728
+ // Insert button box with layout dimensions
729
+ const insertButtonBox = db.prepare('INSERT INTO button_boxes (id, resource_id, layout_x, layout_y, init_size_x, init_size_y) VALUES (?, ?, ?, ?, ?, ?)');
730
+ insertButtonBox.run(buttonBoxId, buttonBoxResourceId, gridWidth, gridHeight, 10000, // init_size_x in internal units
731
+ 10000 // init_size_y in internal units
732
+ );
682
733
  // Create button box instance with calculated dimensions
683
734
  const insertButtonBoxInstance = db.prepare('INSERT INTO button_box_instances (id, page_id, button_box_id, position_x, position_y, size_x, size_y) VALUES (?, ?, ?, ?, ?, ?, ?)');
684
735
  insertButtonBoxInstance.run(buttonBoxInstanceIdCounter++, numericPageId, buttonBoxId, 0, // Box starts at origin
@@ -30,7 +30,7 @@ export declare class MetricsCalculator {
30
30
  * Count scan items for visual scanning effort
31
31
  * When block scanning is enabled, count unique scan blocks instead of individual buttons
32
32
  */
33
- private countScanItems;
33
+ private countScanBlocks;
34
34
  /**
35
35
  * Analyze starting from a specific board
36
36
  */
@@ -221,44 +221,26 @@ class MetricsCalculator {
221
221
  * Count scan items for visual scanning effort
222
222
  * When block scanning is enabled, count unique scan blocks instead of individual buttons
223
223
  */
224
- countScanItems(board, currentRowIndex, currentColIndex, priorScanBlocks, blockScanEnabled) {
225
- if (!blockScanEnabled) {
226
- // Linear scanning: count all buttons before current position
227
- let count = 0;
228
- for (let r = 0; r <= currentRowIndex; r++) {
229
- const row = board.grid[r];
230
- if (!row)
231
- continue;
232
- for (let c = 0; c < row.length; c++) {
233
- if (r === currentRowIndex && c === currentColIndex)
234
- return count;
235
- const btn = row[c];
236
- if (btn && (btn.label || btn.id).length > 0) {
237
- count++;
238
- }
239
- }
240
- }
241
- return count;
242
- }
224
+ countScanBlocks(board, currentRowIndex, currentColIndex, priorScanBlocks) {
243
225
  // Block scanning: count unique scan blocks before current position
244
- const seenBlocks = new Set();
226
+ // Reuse the priorScanBlocks set from the parent scope
245
227
  for (let r = 0; r <= currentRowIndex; r++) {
246
228
  const row = board.grid[r];
247
229
  if (!row)
248
230
  continue;
249
231
  for (let c = 0; c < row.length; c++) {
250
232
  if (r === currentRowIndex && c === currentColIndex)
251
- return seenBlocks.size;
233
+ return priorScanBlocks.size;
252
234
  const btn = row[c];
253
235
  if (btn && (btn.label || btn.id).length > 0) {
254
236
  const block = btn.scanBlock ||
255
237
  (btn.scanBlocks && btn.scanBlocks.length > 0 ? btn.scanBlocks[0] : null);
256
238
  if (block !== null)
257
- seenBlocks.add(block);
239
+ priorScanBlocks.add(block);
258
240
  }
259
241
  }
260
242
  }
261
- return seenBlocks.size;
243
+ return priorScanBlocks.size;
262
244
  }
263
245
  /**
264
246
  * Analyze starting from a specific board
@@ -317,216 +299,213 @@ class MetricsCalculator {
317
299
  const scanningConfig = options.scanningConfig || board.scanningConfig;
318
300
  const blockScanEnabled = scanningConfig?.blockScanEnabled || false;
319
301
  // Process each button
320
- const priorScanBlocks = new Set();
321
302
  const btnHeight = 1.0 / rows;
322
303
  const btnWidth = 1.0 / cols;
323
- // Iterate over all buttons on the page to handle overlapping cells (common in some gridsets like Super Core 50)
324
- // Sort buttons by position to ensure consistent prior_buttons/scanning calculation
325
- const sortedButtons = [...board.buttons]
326
- .filter((b) => (b.label || '').length > 0)
327
- .sort((a, b) => {
328
- if ((a.y ?? 0) !== (b.y ?? 0))
329
- return (a.y ?? 0) - (b.y ?? 0);
330
- return (a.x ?? 0) - (b.x ?? 0);
331
- });
332
- sortedButtons.forEach((btn) => {
333
- const rowIndex = btn.y ?? 0;
334
- const colIndex = btn.x ?? 0;
335
- const x = btnWidth / 2 + btnWidth * colIndex;
336
- const y = btnHeight / 2 + btnHeight * rowIndex;
337
- // Calculate prior items for visual scan effort
338
- // If block scanning enabled, count unique scan blocks instead of individual buttons
339
- const priorItems = this.countScanItems(board, rowIndex, colIndex, priorScanBlocks, blockScanEnabled);
340
- // Calculate button-level effort
341
- let buttonEffort = boardEffort;
342
- // Debug for specific button (disabled for production)
343
- const debugSpecificButton = btn.label === '$938c2cc0dc';
344
- if (debugSpecificButton) {
345
- console.log(`\n🔍 DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:`);
346
- console.log(` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`);
347
- console.log(` Current level: ${level}`);
348
- console.log(` Starting effort: ${buttonEffort.toFixed(6)}`);
349
- }
350
- // Apply semantic_id discounts
351
- if (btn.semantic_id && boardPcts[btn.semantic_id]) {
352
- const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
353
- const old = buttonEffort;
354
- buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
355
- if (debugSpecificButton)
356
- console.log(` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})`);
357
- }
358
- else if (btn.semantic_id && boardPcts[`upstream-${btn.semantic_id}`]) {
359
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
360
- boardPcts[`upstream-${btn.semantic_id}`];
361
- const old = buttonEffort;
362
- buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
363
- if (debugSpecificButton)
364
- console.log(` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})`);
365
- }
366
- // Apply clone_id discounts
367
- if (btn.clone_id && boardPcts[btn.clone_id]) {
368
- const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
369
- buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
370
- }
371
- else if (btn.clone_id && boardPcts[`upstream-${btn.clone_id}`]) {
372
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
373
- boardPcts[`upstream-${btn.clone_id}`];
374
- buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
375
- }
376
- // Calculate button effort based on access method (Touch vs Scanning)
377
- const isScanning = !!scanningConfig || !!board.scanType;
378
- if (isScanning) {
379
- const { steps, selections, loopSteps } = this.calculateScanSteps(board, btn, rowIndex, colIndex, scanningConfig);
380
- // Determine effective costs based on selection method
381
- let currentStepCost = options.scanStepCost ?? effort_1.EFFORT_CONSTANTS.SCAN_STEP_COST;
382
- const currentSelectionCost = options.scanSelectionCost ?? effort_1.EFFORT_CONSTANTS.SCAN_SELECTION_COST;
383
- // Step Scan 2 Switch: Every step is a physical selection with Switch 1
384
- if (scanningConfig?.selectionMethod === aac_1.ScanningSelectionMethod.StepScan2Switch) {
385
- // The cost of moving is now a selection cost
386
- currentStepCost = currentSelectionCost;
387
- }
388
- else if (scanningConfig?.selectionMethod === aac_1.ScanningSelectionMethod.StepScan1Switch) {
389
- // Single switch step scan: every step is a physical selection
390
- currentStepCost = currentSelectionCost;
391
- }
392
- let sEffort = (0, effort_1.scanningEffort)(steps, selections, currentStepCost, currentSelectionCost);
393
- // Factor in error correction if enabled
394
- if (scanningConfig?.errorCorrectionEnabled) {
395
- const errorRate = scanningConfig.errorRate ?? effort_1.EFFORT_CONSTANTS.DEFAULT_SCAN_ERROR_RATE;
396
- // A "miss" results in needing to wait for a loop (or part of one)
397
- // We model this as errorRate * (loopSteps * stepCost)
398
- const retryPenalty = loopSteps * currentStepCost;
399
- sEffort += errorRate * retryPenalty;
304
+ // Track scan blocks for block scanning
305
+ const priorScanBlocks = new Set();
306
+ // Iterate over grid positions directly (not just buttons)
307
+ // This matches Ruby's nested loop: rows.times do |row_idx|; columns.times do |col_idx|
308
+ for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
309
+ for (let colIndex = 0; colIndex < cols; colIndex++) {
310
+ const btn = board.grid[rowIndex]?.[colIndex];
311
+ if (!btn)
312
+ continue; // Skip empty cells
313
+ const x = btnWidth / 2 + btnWidth * colIndex;
314
+ const y = btnHeight / 2 + btnHeight * rowIndex;
315
+ // Calculate prior grid positions (not just buttons)
316
+ // This matches Ruby's prior_buttons which increments for each grid position
317
+ const priorGridPositions = rowIndex * cols + colIndex;
318
+ // For block scanning, count unique scan blocks instead
319
+ const priorItems = blockScanEnabled
320
+ ? this.countScanBlocks(board, rowIndex, colIndex, priorScanBlocks)
321
+ : priorGridPositions;
322
+ // Calculate button-level effort
323
+ let buttonEffort = boardEffort;
324
+ // Debug for specific button (disabled for production)
325
+ const debugSpecificButton = btn.label === '$938c2cc0dc';
326
+ if (debugSpecificButton) {
327
+ console.log(`\n🔍 DEBUG Button ${btn.label} at [${rowIndex},${colIndex}] on ${board.id}:`);
328
+ console.log(` Entry point: (${entryX.toFixed(4)}, ${entryY.toFixed(4)})`);
329
+ console.log(` Current level: ${level}`);
330
+ console.log(` Prior positions: ${priorItems}`);
331
+ console.log(` Starting effort: ${buttonEffort.toFixed(6)}`);
400
332
  }
401
- // Apply discounts to scanning effort (similar to touch)
333
+ // Apply semantic_id discounts
402
334
  if (btn.semantic_id && boardPcts[btn.semantic_id]) {
403
335
  const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
404
- sEffort = Math.min(sEffort, sEffort * discount);
336
+ const old = buttonEffort;
337
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
338
+ if (debugSpecificButton)
339
+ console.log(` Semantic board discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[btn.semantic_id].toFixed(4)})`);
405
340
  }
406
- else if (btn.clone_id && boardPcts[btn.clone_id]) {
341
+ else if (btn.semantic_id && boardPcts[`upstream-${btn.semantic_id}`]) {
342
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
343
+ boardPcts[`upstream-${btn.semantic_id}`];
344
+ const old = buttonEffort;
345
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
346
+ if (debugSpecificButton)
347
+ console.log(` Semantic upstream discount: ${old.toFixed(6)} -> ${buttonEffort.toFixed(6)} (pct=${boardPcts[`upstream-${btn.semantic_id}`].toFixed(4)})`);
348
+ }
349
+ // Apply clone_id discounts
350
+ if (btn.clone_id && boardPcts[btn.clone_id]) {
407
351
  const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
408
- sEffort = Math.min(sEffort, sEffort * discount);
352
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
409
353
  }
410
- buttonEffort += sEffort;
411
- }
412
- else {
413
- // Add distance effort (Touch only)
414
- let distance = (0, effort_1.distanceEffort)(x, y, entryX, entryY);
415
- // Apply distance discounts
416
- if (btn.semantic_id) {
417
- if (boardPcts[btn.semantic_id]) {
418
- const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
419
- distance = Math.min(distance, distance * discount);
420
- }
421
- else if (boardPcts[`upstream-${btn.semantic_id}`]) {
422
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
423
- boardPcts[`upstream-${btn.semantic_id}`];
424
- distance = Math.min(distance, distance * discount);
354
+ else if (btn.clone_id && boardPcts[`upstream-${btn.clone_id}`]) {
355
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
356
+ boardPcts[`upstream-${btn.clone_id}`];
357
+ buttonEffort = Math.min(buttonEffort, buttonEffort * discount);
358
+ }
359
+ // Calculate button effort based on access method (Touch vs Scanning)
360
+ const isScanning = !!scanningConfig || !!board.scanType;
361
+ if (isScanning) {
362
+ const { steps, selections, loopSteps } = this.calculateScanSteps(board, btn, rowIndex, colIndex, scanningConfig);
363
+ // Determine effective costs based on selection method
364
+ let currentStepCost = options.scanStepCost ?? effort_1.EFFORT_CONSTANTS.SCAN_STEP_COST;
365
+ const currentSelectionCost = options.scanSelectionCost ?? effort_1.EFFORT_CONSTANTS.SCAN_SELECTION_COST;
366
+ // Step Scan 2 Switch: Every step is a physical selection with Switch 1
367
+ if (scanningConfig?.selectionMethod === aac_1.ScanningSelectionMethod.StepScan2Switch) {
368
+ // The cost of moving is now a selection cost
369
+ currentStepCost = currentSelectionCost;
425
370
  }
426
- else if (level > 0 && setPcts[btn.semantic_id]) {
427
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT /
428
- setPcts[btn.semantic_id];
429
- distance = Math.min(distance, distance * discount);
371
+ else if (scanningConfig?.selectionMethod === aac_1.ScanningSelectionMethod.StepScan1Switch) {
372
+ // Single switch step scan: every step is a physical selection
373
+ currentStepCost = currentSelectionCost;
430
374
  }
431
- }
432
- if (btn.clone_id) {
433
- if (boardPcts[btn.clone_id]) {
434
- const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
435
- distance = Math.min(distance, distance * discount);
375
+ let sEffort = (0, effort_1.scanningEffort)(steps, selections, currentStepCost, currentSelectionCost);
376
+ // Factor in error correction if enabled
377
+ if (scanningConfig?.errorCorrectionEnabled) {
378
+ const errorRate = scanningConfig.errorRate ?? effort_1.EFFORT_CONSTANTS.DEFAULT_SCAN_ERROR_RATE;
379
+ // A "miss" results in needing to wait for a loop (or part of one)
380
+ // We model this as errorRate * (loopSteps * stepCost)
381
+ const retryPenalty = loopSteps * currentStepCost;
382
+ sEffort += errorRate * retryPenalty;
436
383
  }
437
- else if (boardPcts[`upstream-${btn.clone_id}`]) {
438
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
439
- boardPcts[`upstream-${btn.clone_id}`];
440
- distance = Math.min(distance, distance * discount);
384
+ // Apply discounts to scanning effort (similar to touch)
385
+ if (btn.semantic_id && boardPcts[btn.semantic_id]) {
386
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
387
+ sEffort = Math.min(sEffort, sEffort * discount);
441
388
  }
442
- else if (level > 0 && setPcts[btn.clone_id]) {
443
- const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[btn.clone_id];
444
- distance = Math.min(distance, distance * discount);
389
+ else if (btn.clone_id && boardPcts[btn.clone_id]) {
390
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
391
+ sEffort = Math.min(sEffort, sEffort * discount);
445
392
  }
446
- }
447
- buttonEffort += distance;
448
- // Add visual scan or local scan effort
449
- if (distance > effort_1.EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN ||
450
- (entryX === 1.0 && entryY === 1.0)) {
451
- buttonEffort += (0, effort_1.visualScanEffort)(priorItems);
393
+ buttonEffort += sEffort;
452
394
  }
453
395
  else {
454
- buttonEffort += (0, effort_1.localScanEffort)(distance);
455
- }
456
- }
457
- // Add cumulative prior effort
458
- buttonEffort += priorEffort;
459
- // Track scan blocks for block scanning, otherwise track individual buttons
460
- if (blockScanEnabled) {
461
- const scanBlockId = btn.scanBlock ?? btn.scanBlocks?.[0];
462
- if (scanBlockId !== undefined && scanBlockId !== null) {
463
- priorScanBlocks.add(scanBlockId);
464
- }
465
- }
466
- // Handle navigation buttons
467
- if (btn.targetPageId) {
468
- const nextBoard = tree.getPage(btn.targetPageId);
469
- if (nextBoard) {
470
- // Only add to toVisit if this board hasn't been visited yet at any level
471
- // The visitedBoardIds map stores the *lowest* level a board was visited.
472
- // If it's already in the map, it means we've processed it or scheduled it at a lower level.
473
- if (visitedBoardIds.get(nextBoard.id) === undefined) {
474
- const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
475
- const tempHomeId = btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === 'prior'
476
- ? board.id
477
- : btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === true
478
- ? btn.targetPageId
479
- : temporaryHomeId;
480
- toVisit.push({
481
- board: nextBoard,
482
- level: level + 1,
483
- priorEffort: buttonEffort + changeEffort,
484
- temporaryHomeId: tempHomeId,
485
- entryX: x,
486
- entryY: y,
487
- entryCloneId: btn.clone_id,
488
- entrySemanticId: btn.semantic_id,
489
- });
396
+ // Add distance effort (Touch only)
397
+ let distance = (0, effort_1.distanceEffort)(x, y, entryX, entryY);
398
+ // Apply distance discounts
399
+ if (btn.semantic_id) {
400
+ if (boardPcts[btn.semantic_id]) {
401
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.semantic_id];
402
+ distance = Math.min(distance, distance * discount);
403
+ }
404
+ else if (boardPcts[`upstream-${btn.semantic_id}`]) {
405
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_PRIOR_DISCOUNT /
406
+ boardPcts[`upstream-${btn.semantic_id}`];
407
+ distance = Math.min(distance, distance * discount);
408
+ }
409
+ else if (level > 0 && setPcts[btn.semantic_id]) {
410
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_SEMANTIC_FROM_OTHER_DISCOUNT /
411
+ setPcts[btn.semantic_id];
412
+ distance = Math.min(distance, distance * discount);
413
+ }
414
+ }
415
+ if (btn.clone_id) {
416
+ if (boardPcts[btn.clone_id]) {
417
+ const discount = effort_1.EFFORT_CONSTANTS.SAME_LOCATION_AS_PRIOR_DISCOUNT / boardPcts[btn.clone_id];
418
+ distance = Math.min(distance, distance * discount);
419
+ }
420
+ else if (boardPcts[`upstream-${btn.clone_id}`]) {
421
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_PRIOR_DISCOUNT /
422
+ boardPcts[`upstream-${btn.clone_id}`];
423
+ distance = Math.min(distance, distance * discount);
424
+ }
425
+ else if (level > 0 && setPcts[btn.clone_id]) {
426
+ const discount = effort_1.EFFORT_CONSTANTS.RECOGNIZABLE_CLONE_FROM_OTHER_DISCOUNT / setPcts[btn.clone_id];
427
+ distance = Math.min(distance, distance * discount);
428
+ }
429
+ }
430
+ buttonEffort += distance;
431
+ // Add visual scan or local scan effort
432
+ if (distance > effort_1.EFFORT_CONSTANTS.DISTANCE_THRESHOLD_TO_SKIP_VISUAL_SCAN ||
433
+ (entryX === 1.0 && entryY === 1.0)) {
434
+ buttonEffort += (0, effort_1.visualScanEffort)(priorItems);
435
+ }
436
+ else {
437
+ buttonEffort += (0, effort_1.localScanEffort)(distance);
490
438
  }
491
439
  }
492
- }
493
- // Track word if it speaks or adds to sentence
494
- const intent = String(btn.semanticAction?.intent || '');
495
- const isSpeak = btn.semanticAction?.category === treeStructure_1.AACSemanticCategory.COMMUNICATION ||
496
- intent === 'SPEAK_TEXT' ||
497
- intent === 'SPEAK_IMMEDIATE' ||
498
- intent === 'INSERT_TEXT' ||
499
- btn.semanticAction?.fallback?.type === 'SPEAK';
500
- const addToSentence = btn.semanticAction?.platformData?.grid3?.parameters?.add_to_sentence ||
501
- btn.semanticAction?.fallback?.add_to_sentence;
502
- if (isSpeak || addToSentence) {
503
- let finalEffort = buttonEffort;
504
- // Apply Board Change Processing Effort Discount (matching Ruby lines 347-350)
505
- const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
506
- if (btn.clone_id && boardPcts[btn.clone_id]) {
507
- const discount = Math.min(changeEffort, (changeEffort * 0.3) / boardPcts[btn.clone_id]);
508
- finalEffort -= discount;
440
+ // Add cumulative prior effort
441
+ buttonEffort += priorEffort;
442
+ // Track scan blocks for block scanning, otherwise track individual buttons
443
+ if (blockScanEnabled) {
444
+ const scanBlockId = btn.scanBlock ?? btn.scanBlocks?.[0];
445
+ if (scanBlockId !== undefined && scanBlockId !== null) {
446
+ priorScanBlocks.add(scanBlockId);
447
+ }
509
448
  }
510
- else if (btn.semantic_id && boardPcts[btn.semantic_id]) {
511
- const discount = Math.min(changeEffort, (changeEffort * 0.5) / boardPcts[btn.semantic_id]);
512
- finalEffort -= discount;
449
+ // Handle navigation buttons
450
+ if (btn.targetPageId) {
451
+ const nextBoard = tree.getPage(btn.targetPageId);
452
+ if (nextBoard) {
453
+ // Only add to toVisit if this board hasn't been visited yet at any level
454
+ // The visitedBoardIds map stores the *lowest* level a board was visited.
455
+ // If it's already in the map, it means we've processed it or scheduled it at a lower level.
456
+ if (visitedBoardIds.get(nextBoard.id) === undefined) {
457
+ const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
458
+ const tempHomeId = btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === 'prior'
459
+ ? board.id
460
+ : btn.semanticAction?.platformData?.grid3?.parameters?.temporary_home === true
461
+ ? btn.targetPageId
462
+ : temporaryHomeId;
463
+ toVisit.push({
464
+ board: nextBoard,
465
+ level: level + 1,
466
+ priorEffort: buttonEffort + changeEffort,
467
+ temporaryHomeId: tempHomeId,
468
+ entryX: x,
469
+ entryY: y,
470
+ entryCloneId: btn.clone_id,
471
+ entrySemanticId: btn.semantic_id,
472
+ });
473
+ }
474
+ }
513
475
  }
514
- const existing = knownButtons.get(btn.label);
515
- const knownBtn = {
516
- id: btn.id,
517
- label: btn.label,
518
- level,
519
- effort: finalEffort,
520
- count: (existing?.count || 0) + 1,
521
- semantic_id: btn.semantic_id,
522
- clone_id: btn.clone_id,
523
- temporary_home_id: temporaryHomeId || undefined,
524
- };
525
- if (!existing || finalEffort < existing.effort) {
526
- knownButtons.set(btn.label, knownBtn);
476
+ // Track word if it speaks or adds to sentence
477
+ const isSpeak = btn.semanticAction?.category === treeStructure_1.AACSemanticCategory.COMMUNICATION && !btn.targetPageId; // Must not be a navigation button
478
+ const addToSentence = btn.semanticAction?.platformData?.grid3?.parameters?.add_to_sentence ||
479
+ btn.semanticAction?.fallback?.add_to_sentence;
480
+ if (isSpeak || addToSentence) {
481
+ let finalEffort = buttonEffort;
482
+ // Apply Board Change Processing Effort Discount (matching Ruby lines 347-350)
483
+ const changeEffort = effort_1.EFFORT_CONSTANTS.BOARD_CHANGE_PROCESSING_EFFORT;
484
+ if (btn.clone_id && boardPcts[btn.clone_id]) {
485
+ const discount = Math.min(changeEffort, (changeEffort * 0.3) / boardPcts[btn.clone_id]);
486
+ finalEffort -= discount;
487
+ }
488
+ else if (btn.semantic_id && boardPcts[btn.semantic_id]) {
489
+ const discount = Math.min(changeEffort, (changeEffort * 0.5) / boardPcts[btn.semantic_id]);
490
+ finalEffort -= discount;
491
+ }
492
+ const existing = knownButtons.get(btn.label);
493
+ const knownBtn = {
494
+ id: btn.id,
495
+ label: btn.label,
496
+ level,
497
+ effort: finalEffort,
498
+ count: (existing?.count || 0) + 1,
499
+ semantic_id: btn.semantic_id,
500
+ clone_id: btn.clone_id,
501
+ temporary_home_id: temporaryHomeId || undefined,
502
+ };
503
+ if (!existing || finalEffort < existing.effort) {
504
+ knownButtons.set(btn.label, knownBtn);
505
+ }
527
506
  }
528
507
  }
529
- });
508
+ }
530
509
  }
531
510
  // Convert to array and group by level
532
511
  const buttons = Array.from(knownButtons.values());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@willwade/aac-processors",
3
- "version": "0.0.21",
3
+ "version": "0.0.22",
4
4
  "description": "A comprehensive TypeScript library for processing AAC (Augmentative and Alternative Communication) file formats with translation support",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",