@willwade/aac-processors 0.0.26 → 0.0.28
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/processors/gridsetProcessor.d.ts +5 -0
- package/dist/processors/gridsetProcessor.js +86 -16
- package/dist/processors/obfProcessor.d.ts +11 -0
- package/dist/processors/obfProcessor.js +116 -0
- package/dist/utilities/analytics/metrics/core.d.ts +17 -0
- package/dist/utilities/analytics/metrics/core.js +96 -0
- package/dist/utilities/analytics/metrics/types.d.ts +17 -0
- package/package.json +1 -1
|
@@ -12,6 +12,11 @@ declare class GridsetProcessor extends BaseProcessor {
|
|
|
12
12
|
private decryptGridsetEntry;
|
|
13
13
|
private getGridsetPassword;
|
|
14
14
|
private ensureAlphaChannel;
|
|
15
|
+
/**
|
|
16
|
+
* Calculate appropriate font color (black or white) based on background brightness
|
|
17
|
+
* Uses WCAG relative luminance formula to determine contrast
|
|
18
|
+
*/
|
|
19
|
+
private getContrastFontColor;
|
|
15
20
|
/**
|
|
16
21
|
* Extract words from Grid3 WordList structure
|
|
17
22
|
*/
|
|
@@ -58,6 +58,19 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
58
58
|
ensureAlphaChannel(color) {
|
|
59
59
|
if (!color)
|
|
60
60
|
return '#FFFFFFFF';
|
|
61
|
+
// Handle rgb() and rgba() formats
|
|
62
|
+
const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
|
63
|
+
if (rgbMatch) {
|
|
64
|
+
const r = parseInt(rgbMatch[1]);
|
|
65
|
+
const g = parseInt(rgbMatch[2]);
|
|
66
|
+
const b = parseInt(rgbMatch[3]);
|
|
67
|
+
const a = rgbMatch[4] !== undefined ? parseFloat(rgbMatch[4]) : 1.0;
|
|
68
|
+
const alphaHex = Math.round(a * 255)
|
|
69
|
+
.toString(16)
|
|
70
|
+
.toUpperCase()
|
|
71
|
+
.padStart(2, '0');
|
|
72
|
+
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}${alphaHex}`;
|
|
73
|
+
}
|
|
61
74
|
// If already 8 digits (with alpha), return as is
|
|
62
75
|
if (color.match(/^#[0-9A-Fa-f]{8}$/))
|
|
63
76
|
return color;
|
|
@@ -74,6 +87,37 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
74
87
|
// Invalid or unknown format, return white
|
|
75
88
|
return '#FFFFFFFF';
|
|
76
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Calculate appropriate font color (black or white) based on background brightness
|
|
92
|
+
* Uses WCAG relative luminance formula to determine contrast
|
|
93
|
+
*/
|
|
94
|
+
getContrastFontColor(backgroundColor) {
|
|
95
|
+
if (!backgroundColor)
|
|
96
|
+
return '#FF000000FF'; // Default to black
|
|
97
|
+
// Parse color from various formats
|
|
98
|
+
let r = 255, g = 255, b = 255;
|
|
99
|
+
// Handle hex colors
|
|
100
|
+
const hexMatch = backgroundColor.match(/#?([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})/);
|
|
101
|
+
if (hexMatch) {
|
|
102
|
+
r = parseInt(hexMatch[1], 16);
|
|
103
|
+
g = parseInt(hexMatch[2], 16);
|
|
104
|
+
b = parseInt(hexMatch[3], 16);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// Handle rgb() format
|
|
108
|
+
const rgbMatch = backgroundColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
109
|
+
if (rgbMatch) {
|
|
110
|
+
r = parseInt(rgbMatch[1]);
|
|
111
|
+
g = parseInt(rgbMatch[2]);
|
|
112
|
+
b = parseInt(rgbMatch[3]);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Calculate relative luminance using WCAG formula
|
|
116
|
+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
117
|
+
// Use white text for dark backgrounds (luminance < 0.5), black for light backgrounds
|
|
118
|
+
// Return 6-digit hex (ensureAlphaChannel will add FF for alpha)
|
|
119
|
+
return luminance < 0.5 ? '#FFFFFF' : '#000000';
|
|
120
|
+
}
|
|
77
121
|
/**
|
|
78
122
|
* Extract words from Grid3 WordList structure
|
|
79
123
|
*/
|
|
@@ -1628,7 +1672,8 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
1628
1672
|
// For "None" surround, just use BackColour for the fill (no TileColour)
|
|
1629
1673
|
BackColour: this.ensureAlphaChannel(style.backgroundColor),
|
|
1630
1674
|
BorderColour: this.ensureAlphaChannel(style.borderColor),
|
|
1631
|
-
|
|
1675
|
+
// Calculate font color based on background if not explicitly set
|
|
1676
|
+
FontColour: this.ensureAlphaChannel(style.fontColor || this.getContrastFontColor(style.backgroundColor)),
|
|
1632
1677
|
FontName: style.fontFamily || 'Arial',
|
|
1633
1678
|
FontSize: style.fontSize?.toString() || '16',
|
|
1634
1679
|
};
|
|
@@ -1687,26 +1732,50 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
1687
1732
|
if (imageMatch) {
|
|
1688
1733
|
imageExt = imageMatch[1].toLowerCase();
|
|
1689
1734
|
}
|
|
1690
|
-
// Grid3 dynamically constructs image filenames by prepending cell coordinates
|
|
1691
|
-
// The XML should only contain the suffix: -0-text-0.{ext}
|
|
1692
|
-
// Grid3 automatically adds the X-Y prefix based on the Cell's position
|
|
1693
|
-
captionAndImage.Image = `-0-text-0.${imageExt}`;
|
|
1694
1735
|
// Extract image data from button parameters if available
|
|
1695
1736
|
// (AstericsGridProcessor stores it there during loadIntoTree)
|
|
1737
|
+
// Also handle data URLs from OBZ conversion
|
|
1696
1738
|
let imageData = Buffer.alloc(0);
|
|
1739
|
+
let hasImageData = false;
|
|
1697
1740
|
if (button.parameters &&
|
|
1698
1741
|
button.parameters.imageData &&
|
|
1699
1742
|
Buffer.isBuffer(button.parameters.imageData)) {
|
|
1700
1743
|
imageData = button.parameters.imageData;
|
|
1744
|
+
hasImageData = imageData.length > 0;
|
|
1745
|
+
}
|
|
1746
|
+
else if (button.image &&
|
|
1747
|
+
typeof button.image === 'string' &&
|
|
1748
|
+
button.image.startsWith('data:image')) {
|
|
1749
|
+
// Convert data URL to Buffer (for OBZ → Grid3 conversion)
|
|
1750
|
+
try {
|
|
1751
|
+
const matches = button.image.match(/^data:image\/(\w+);base64,(.+)$/);
|
|
1752
|
+
if (matches) {
|
|
1753
|
+
const extension = matches[1]; // e.g., 'png', 'jpeg', 'gif'
|
|
1754
|
+
const base64Data = matches[2];
|
|
1755
|
+
imageData = Buffer.from(base64Data, 'base64');
|
|
1756
|
+
imageExt = extension; // Override the detected extension
|
|
1757
|
+
hasImageData = imageData.length > 0;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
catch (err) {
|
|
1761
|
+
console.warn(`[Grid3] Failed to convert data URL to Buffer for button ${button.id}:`, err);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
// Only add image reference if we have actual image data
|
|
1765
|
+
if (hasImageData) {
|
|
1766
|
+
// Grid3 dynamically constructs image filenames by prepending cell coordinates
|
|
1767
|
+
// The XML should only contain the suffix: -0-text-0.{ext}
|
|
1768
|
+
// Grid3 automatically adds the X-Y prefix based on the Cell's position
|
|
1769
|
+
captionAndImage.Image = `-0-text-0.${imageExt}`;
|
|
1770
|
+
// Store image data for later writing to ZIP
|
|
1771
|
+
buttonImages.set(button.id, {
|
|
1772
|
+
imageData: imageData,
|
|
1773
|
+
ext: imageExt,
|
|
1774
|
+
pageName: page.name || page.id,
|
|
1775
|
+
x: position.x,
|
|
1776
|
+
y: position.y + yOffset,
|
|
1777
|
+
});
|
|
1701
1778
|
}
|
|
1702
|
-
// Store image data for later writing to ZIP
|
|
1703
|
-
buttonImages.set(button.id, {
|
|
1704
|
-
imageData: imageData,
|
|
1705
|
-
ext: imageExt,
|
|
1706
|
-
pageName: page.name || page.id,
|
|
1707
|
-
x: position.x,
|
|
1708
|
-
y: position.y + yOffset,
|
|
1709
|
-
});
|
|
1710
1779
|
}
|
|
1711
1780
|
const cellData = {
|
|
1712
1781
|
'@_X': position.x, // Grid3 uses 0-based X coordinates (defaults to 0 when omitted)
|
|
@@ -1736,9 +1805,10 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
1736
1805
|
if (button.style?.borderColor) {
|
|
1737
1806
|
styleObj.BorderColour = this.ensureAlphaChannel(button.style.borderColor);
|
|
1738
1807
|
}
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1808
|
+
// Always add font color inline - either from button style or calculated from background
|
|
1809
|
+
const fontColor = button.style?.fontColor ||
|
|
1810
|
+
this.getContrastFontColor(button.style?.backgroundColor);
|
|
1811
|
+
styleObj.FontColour = this.ensureAlphaChannel(fontColor);
|
|
1742
1812
|
if (button.style?.fontFamily) {
|
|
1743
1813
|
styleObj.FontName = button.style.fontFamily;
|
|
1744
1814
|
}
|
|
@@ -3,7 +3,18 @@ import { AACTree } from '../core/treeStructure';
|
|
|
3
3
|
import { ValidationResult } from '../validation/validationTypes';
|
|
4
4
|
import { type ButtonForTranslation, type LLMLTranslationResult } from '../utilities/translation/translationProcessor';
|
|
5
5
|
declare class ObfProcessor extends BaseProcessor {
|
|
6
|
+
private zipFile?;
|
|
7
|
+
private imageCache;
|
|
6
8
|
constructor(options?: ProcessorOptions);
|
|
9
|
+
/**
|
|
10
|
+
* Extract an image from the ZIP file as a Buffer
|
|
11
|
+
*/
|
|
12
|
+
private extractImageAsBuffer;
|
|
13
|
+
/**
|
|
14
|
+
* Extract an image from the ZIP file and convert to data URL
|
|
15
|
+
*/
|
|
16
|
+
private extractImageAsDataUrl;
|
|
17
|
+
private getMimeTypeFromFilename;
|
|
7
18
|
private processBoard;
|
|
8
19
|
extractTexts(filePathOrBuffer: string | Buffer): string[];
|
|
9
20
|
loadIntoTree(filePathOrBuffer: string | Buffer): AACTree;
|
|
@@ -26,6 +26,104 @@ function mapObfVisibility(hidden) {
|
|
|
26
26
|
class ObfProcessor extends baseProcessor_1.BaseProcessor {
|
|
27
27
|
constructor(options) {
|
|
28
28
|
super(options);
|
|
29
|
+
this.imageCache = new Map(); // Cache for data URLs
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Extract an image from the ZIP file as a Buffer
|
|
33
|
+
*/
|
|
34
|
+
extractImageAsBuffer(imageId, images) {
|
|
35
|
+
if (!this.zipFile || !images) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
// Find the image metadata
|
|
39
|
+
const imageData = images.find((img) => img.id === imageId);
|
|
40
|
+
if (!imageData) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
// Try to get the image file from the ZIP
|
|
44
|
+
const possiblePaths = [
|
|
45
|
+
imageData.path,
|
|
46
|
+
`images/${imageData.filename || imageId}`,
|
|
47
|
+
imageData.id,
|
|
48
|
+
].filter(Boolean);
|
|
49
|
+
for (const imagePath of possiblePaths) {
|
|
50
|
+
try {
|
|
51
|
+
const entry = this.zipFile.getEntry(imagePath);
|
|
52
|
+
if (entry) {
|
|
53
|
+
return entry.getData(); // Return raw Buffer
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Extract an image from the ZIP file and convert to data URL
|
|
64
|
+
*/
|
|
65
|
+
extractImageAsDataUrl(imageId, images) {
|
|
66
|
+
// Check cache first
|
|
67
|
+
if (this.imageCache.has(imageId)) {
|
|
68
|
+
return this.imageCache.get(imageId) ?? null;
|
|
69
|
+
}
|
|
70
|
+
if (!this.zipFile || !images) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
// Find the image metadata
|
|
74
|
+
const imageData = images.find((img) => img.id === imageId);
|
|
75
|
+
if (!imageData) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
// Try to get the image file from the ZIP
|
|
79
|
+
// Images are typically stored in an 'images' folder or root
|
|
80
|
+
const possiblePaths = [
|
|
81
|
+
imageData.path, // Explicit path if provided
|
|
82
|
+
`images/${imageData.filename || imageId}`, // Standard images folder
|
|
83
|
+
imageData.id, // Just the ID
|
|
84
|
+
].filter(Boolean);
|
|
85
|
+
for (const imagePath of possiblePaths) {
|
|
86
|
+
try {
|
|
87
|
+
const entry = this.zipFile.getEntry(imagePath);
|
|
88
|
+
if (entry) {
|
|
89
|
+
const buffer = entry.getData();
|
|
90
|
+
const contentType = imageData.content_type ||
|
|
91
|
+
this.getMimeTypeFromFilename(imagePath);
|
|
92
|
+
const dataUrl = `data:${contentType};base64,${buffer.toString('base64')}`;
|
|
93
|
+
this.imageCache.set(imageId, dataUrl);
|
|
94
|
+
return dataUrl;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
// Continue to next path
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// If image has a URL, use that as fallback
|
|
103
|
+
if (imageData.url) {
|
|
104
|
+
const url = imageData.url;
|
|
105
|
+
this.imageCache.set(imageId, url);
|
|
106
|
+
return url;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
getMimeTypeFromFilename(filename) {
|
|
111
|
+
const ext = filename.toLowerCase().split('.').pop();
|
|
112
|
+
switch (ext) {
|
|
113
|
+
case 'png':
|
|
114
|
+
return 'image/png';
|
|
115
|
+
case 'jpg':
|
|
116
|
+
case 'jpeg':
|
|
117
|
+
return 'image/jpeg';
|
|
118
|
+
case 'gif':
|
|
119
|
+
return 'image/gif';
|
|
120
|
+
case 'svg':
|
|
121
|
+
return 'image/svg+xml';
|
|
122
|
+
case 'webp':
|
|
123
|
+
return 'image/webp';
|
|
124
|
+
default:
|
|
125
|
+
return 'image/png';
|
|
126
|
+
}
|
|
29
127
|
}
|
|
30
128
|
processBoard(boardData, _boardPath) {
|
|
31
129
|
const sourceButtons = boardData.buttons || [];
|
|
@@ -55,6 +153,18 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
55
153
|
message: String(btn?.vocalization || btn?.label || ''),
|
|
56
154
|
},
|
|
57
155
|
};
|
|
156
|
+
// Resolve image if image_id is present
|
|
157
|
+
let resolvedImage;
|
|
158
|
+
let imageBuffer;
|
|
159
|
+
if (btn.image_id && boardData.images) {
|
|
160
|
+
resolvedImage = this.extractImageAsDataUrl(btn.image_id, boardData.images) || undefined;
|
|
161
|
+
imageBuffer = this.extractImageAsBuffer(btn.image_id, boardData.images) || undefined;
|
|
162
|
+
}
|
|
163
|
+
// Build parameters object for Grid3 export compatibility
|
|
164
|
+
const buttonParameters = {};
|
|
165
|
+
if (imageBuffer) {
|
|
166
|
+
buttonParameters.imageData = imageBuffer;
|
|
167
|
+
}
|
|
58
168
|
return new treeStructure_1.AACButton({
|
|
59
169
|
// Make button ID unique by combining page ID and button ID
|
|
60
170
|
id: `${pageId}::${btn?.id || ''}`,
|
|
@@ -65,6 +175,9 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
65
175
|
backgroundColor: btn.background_color,
|
|
66
176
|
borderColor: btn.border_color,
|
|
67
177
|
},
|
|
178
|
+
image: resolvedImage, // Set the resolved image data URL
|
|
179
|
+
resolvedImageEntry: resolvedImage,
|
|
180
|
+
parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined,
|
|
68
181
|
semanticAction,
|
|
69
182
|
targetPageId: btn.load_board?.path,
|
|
70
183
|
semantic_id: btn.semantic_id, // Extract semantic_id if present
|
|
@@ -271,6 +384,9 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
271
384
|
console.error('[OBF] Error instantiating AdmZip with input:', err);
|
|
272
385
|
throw err;
|
|
273
386
|
}
|
|
387
|
+
// Store the ZIP file reference for image extraction
|
|
388
|
+
this.zipFile = zip;
|
|
389
|
+
this.imageCache.clear(); // Clear cache for new file
|
|
274
390
|
console.log('[OBF] Detected zip archive, extracting .obf files');
|
|
275
391
|
zip.getEntries().forEach((entry) => {
|
|
276
392
|
if (entry.entryName.endsWith('.obf')) {
|
|
@@ -39,6 +39,23 @@ export declare class MetricsCalculator {
|
|
|
39
39
|
* Calculate what percentage of links to this board match semantic_id/clone_id
|
|
40
40
|
*/
|
|
41
41
|
private calculateBoardLinkPercentages;
|
|
42
|
+
/**
|
|
43
|
+
* Calculate metrics for word forms (smart grammar predictions)
|
|
44
|
+
*
|
|
45
|
+
* Word forms are dynamically generated and not part of the tree structure.
|
|
46
|
+
* Their effort is calculated as:
|
|
47
|
+
* - Parent button's cumulative effort (to reach the button)
|
|
48
|
+
* - + Effort to select the word form from its position in predictions grid
|
|
49
|
+
*
|
|
50
|
+
* If a word exists as both a regular button and a word form, the version
|
|
51
|
+
* with lower effort is kept.
|
|
52
|
+
*
|
|
53
|
+
* @param tree - The AAC tree
|
|
54
|
+
* @param buttons - Already calculated button metrics
|
|
55
|
+
* @param options - Metrics options
|
|
56
|
+
* @returns Object containing word form metrics and labels that were replaced
|
|
57
|
+
*/
|
|
58
|
+
private calculateWordFormMetrics;
|
|
42
59
|
/**
|
|
43
60
|
* Calculate grid dimensions from the tree
|
|
44
61
|
*/
|
|
@@ -83,6 +83,20 @@ class MetricsCalculator {
|
|
|
83
83
|
});
|
|
84
84
|
// Update buttons using dynamic spelling effort if applicable
|
|
85
85
|
const buttons = Array.from(knownButtons.values()).sort((a, b) => a.effort - b.effort);
|
|
86
|
+
// Calculate metrics for word forms (smart grammar predictions) if enabled
|
|
87
|
+
// Default to true if not specified
|
|
88
|
+
const useSmartGrammar = options.useSmartGrammar !== false;
|
|
89
|
+
if (useSmartGrammar) {
|
|
90
|
+
const { wordFormMetrics, replacedLabels } = this.calculateWordFormMetrics(tree, buttons, options);
|
|
91
|
+
// Remove buttons that were replaced by lower-effort word forms
|
|
92
|
+
const filteredButtons = buttons.filter((btn) => !replacedLabels.has(btn.label.toLowerCase()));
|
|
93
|
+
// Add word forms and re-sort
|
|
94
|
+
filteredButtons.push(...wordFormMetrics);
|
|
95
|
+
filteredButtons.sort((a, b) => a.effort - b.effort);
|
|
96
|
+
// Replace the original buttons array
|
|
97
|
+
buttons.length = 0;
|
|
98
|
+
buttons.push(...filteredButtons);
|
|
99
|
+
}
|
|
86
100
|
// Calculate grid dimensions
|
|
87
101
|
const grid = this.calculateGridDimensions(tree);
|
|
88
102
|
// Identify prediction metrics
|
|
@@ -560,6 +574,88 @@ class MetricsCalculator {
|
|
|
560
574
|
boardPcts['all'] = totalLinks;
|
|
561
575
|
return boardPcts;
|
|
562
576
|
}
|
|
577
|
+
/**
|
|
578
|
+
* Calculate metrics for word forms (smart grammar predictions)
|
|
579
|
+
*
|
|
580
|
+
* Word forms are dynamically generated and not part of the tree structure.
|
|
581
|
+
* Their effort is calculated as:
|
|
582
|
+
* - Parent button's cumulative effort (to reach the button)
|
|
583
|
+
* - + Effort to select the word form from its position in predictions grid
|
|
584
|
+
*
|
|
585
|
+
* If a word exists as both a regular button and a word form, the version
|
|
586
|
+
* with lower effort is kept.
|
|
587
|
+
*
|
|
588
|
+
* @param tree - The AAC tree
|
|
589
|
+
* @param buttons - Already calculated button metrics
|
|
590
|
+
* @param options - Metrics options
|
|
591
|
+
* @returns Object containing word form metrics and labels that were replaced
|
|
592
|
+
*/
|
|
593
|
+
calculateWordFormMetrics(tree, buttons, _options = {}) {
|
|
594
|
+
const wordFormMetrics = [];
|
|
595
|
+
const replacedLabels = new Set();
|
|
596
|
+
// Track buttons by label to compare efforts
|
|
597
|
+
const existingLabels = new Map();
|
|
598
|
+
buttons.forEach((btn) => existingLabels.set(btn.label.toLowerCase(), btn));
|
|
599
|
+
// Iterate through all pages to find buttons with predictions
|
|
600
|
+
Object.values(tree.pages).forEach((page) => {
|
|
601
|
+
page.grid.forEach((row) => {
|
|
602
|
+
row.forEach((btn) => {
|
|
603
|
+
if (!btn || !btn.predictions || btn.predictions.length === 0)
|
|
604
|
+
return;
|
|
605
|
+
// Find the parent button's metrics
|
|
606
|
+
const parentMetrics = buttons.find((b) => b.id === btn.id);
|
|
607
|
+
if (!parentMetrics)
|
|
608
|
+
return;
|
|
609
|
+
// Calculate effort for each word form
|
|
610
|
+
btn.predictions.forEach((wordForm, index) => {
|
|
611
|
+
const wordFormLower = wordForm.toLowerCase();
|
|
612
|
+
// Calculate effort based on position in predictions array
|
|
613
|
+
// Assume predictions are displayed in a grid layout (e.g., 2 columns)
|
|
614
|
+
const predictionsGridCols = 2; // Typical predictions layout
|
|
615
|
+
const predictionRowIndex = Math.floor(index / predictionsGridCols);
|
|
616
|
+
const predictionColIndex = index % predictionsGridCols;
|
|
617
|
+
// Calculate visual scan effort to reach this word form position
|
|
618
|
+
// Using similar logic to button scanning effort
|
|
619
|
+
const predictionPriorItems = predictionRowIndex * predictionsGridCols + predictionColIndex;
|
|
620
|
+
const predictionSelectionEffort = (0, effort_1.visualScanEffort)(predictionPriorItems);
|
|
621
|
+
// Word form effort = parent button's cumulative effort + selection effort
|
|
622
|
+
const wordFormEffort = parentMetrics.effort + predictionSelectionEffort;
|
|
623
|
+
// Check if this word already exists as a regular button
|
|
624
|
+
const existingBtn = existingLabels.get(wordFormLower);
|
|
625
|
+
// If word exists and has lower or equal effort, skip the word form
|
|
626
|
+
if (existingBtn && existingBtn.effort <= wordFormEffort) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
// If word exists but word form has lower effort, mark for replacement
|
|
630
|
+
if (existingBtn && existingBtn.effort > wordFormEffort) {
|
|
631
|
+
replacedLabels.add(wordFormLower);
|
|
632
|
+
}
|
|
633
|
+
// Create word form metric
|
|
634
|
+
const wordFormBtn = {
|
|
635
|
+
id: `${btn.id}_wordform_${index}`,
|
|
636
|
+
label: wordForm,
|
|
637
|
+
level: parentMetrics.level,
|
|
638
|
+
effort: wordFormEffort,
|
|
639
|
+
count: 1,
|
|
640
|
+
semantic_id: parentMetrics.semantic_id,
|
|
641
|
+
clone_id: parentMetrics.clone_id,
|
|
642
|
+
temporary_home_id: parentMetrics.temporary_home_id,
|
|
643
|
+
is_word_form: true, // Mark this as a word form metric
|
|
644
|
+
parent_button_id: btn.id, // Track parent button
|
|
645
|
+
parent_button_label: parentMetrics.label, // Track parent label
|
|
646
|
+
};
|
|
647
|
+
wordFormMetrics.push(wordFormBtn);
|
|
648
|
+
existingLabels.set(wordFormLower, wordFormBtn);
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
console.log(`📝 Calculated ${wordFormMetrics.length} word form metrics` +
|
|
654
|
+
(replacedLabels.size > 0
|
|
655
|
+
? ` (${replacedLabels.size} replaced higher-effort buttons: ${Array.from(replacedLabels).join(', ')})`
|
|
656
|
+
: ''));
|
|
657
|
+
return { wordFormMetrics, replacedLabels };
|
|
658
|
+
}
|
|
563
659
|
/**
|
|
564
660
|
* Calculate grid dimensions from the tree
|
|
565
661
|
*/
|
|
@@ -18,6 +18,9 @@ export interface ButtonMetrics {
|
|
|
18
18
|
temporary_home_id?: string;
|
|
19
19
|
comp_level?: number;
|
|
20
20
|
comp_effort?: number;
|
|
21
|
+
is_word_form?: boolean;
|
|
22
|
+
parent_button_id?: string;
|
|
23
|
+
parent_button_label?: string;
|
|
21
24
|
}
|
|
22
25
|
/**
|
|
23
26
|
* Board/page level analysis result
|
|
@@ -114,6 +117,20 @@ export interface MetricsOptions {
|
|
|
114
117
|
* Default is 1.5 (checking 1-2 predictions on average).
|
|
115
118
|
*/
|
|
116
119
|
predictionSelections?: number;
|
|
120
|
+
/**
|
|
121
|
+
* Whether to include smart grammar word forms in metrics
|
|
122
|
+
*
|
|
123
|
+
* When true (default): Word forms from smart grammar predictions are included
|
|
124
|
+
* in the metrics. If a word exists as both a regular button and a word form,
|
|
125
|
+
* the version with lower effort is used.
|
|
126
|
+
*
|
|
127
|
+
* When false: Smart grammar word forms are excluded from metrics. Only actual
|
|
128
|
+
* buttons in the tree are analyzed.
|
|
129
|
+
*
|
|
130
|
+
* Only applicable to systems that support smart grammar (e.g., Grid 3).
|
|
131
|
+
* Default is true.
|
|
132
|
+
*/
|
|
133
|
+
useSmartGrammar?: boolean;
|
|
117
134
|
}
|
|
118
135
|
/**
|
|
119
136
|
* Comparison result between two board sets
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@willwade/aac-processors",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.28",
|
|
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",
|