/**
* @namespace utils
*/
import isNumber from 'lodash/isNumber';
import isEmpty from 'lodash/isEmpty';
import random from 'lodash/random';
import round from 'lodash/round';
import startCase from 'lodash/startCase';
import uniq from 'lodash/uniq';
import stats from 'stats-lite';
import UAParser from 'ua-parser-js';
import uFuzzy from '@leeoniya/ufuzzy';
import wordsToNumbers from 'words-to-numbers';
import settings from './settings';
const fuzzy = uFuzzy();
/**
* Finds the operating system of the user.
* @memberOf utils
* @returns {Object} - The OS details from the UAParser library.
*/
const os = new UAParser().getOS();
/**
* Generates a verbose human-friendly response prefixing user's query.
* @memberOf utils
* @returns {string} - Human-friendly verbose response.
*/
const getFeedbackText = () => {
const feedbacks = ['I understand you said', 'It seems like you asked'];
const randomIndex = random(0, feedbacks.length - 1);
return feedbacks[randomIndex];
};
/**
* Finds the modifier using the settings. A modifier is the set of key bindings to trigger responses.
* @memberOf utils
* @param {Object} settings - Settings for VoxLens based on the OS.
* @param {boolean} withSpaces - Determines if multiple modifiers should be combined using spaces instead of '+'.
* @param {boolean} uppercase - Determines if return type should be uppercased
* @param {string} joiningCharacter - The joining character for multiple modifiers. Defaults to '+'.
* @returns {string} - Human-friendly verbose response.
*/
export const getModifier = (
settings,
withSpaces = true,
uppercase = true,
joiningCharacter = '+'
) => {
if (withSpaces) {
joiningCharacter = ' ' + joiningCharacter + ' ';
}
const modifier = settings.multipleModifiers
? settings.modifier.join(joiningCharacter)
: settings.modifier;
return uppercase ? modifier.toUpperCase() : modifier;
};
/**
* Finds the defaults for VoxLens.
* @memberOf utils
* @param {Object} options - Options supplied to VoxLens during initiation.
* @returns {Object} - The default settings and variables.
*/
export const getDefaults = (options = {}) => ({
triggers: {
mainKey: ['a', '1'],
instructionsKey: ['i', '4'],
trendKey: ['m', '3'],
summaryKey: ['s', '2'],
pause: ['p', '5'],
},
xLabel: options.x,
yLabel: options.y,
});
/**
* Maps the triggers to a human-readable format to be used in instructions.
* @memberOf utils
* @param {Object} triggers - Triggers for each VoxLens mode.
* @param {string} modifier - The key binding for the trigger.
* @returns {Object} - Triggers with their presentable values.
*/
const getMappedTriggers = (triggers, modifier) => {
let mappedTriggers = {};
Object.keys(triggers).forEach((k) => {
mappedTriggers[k] = triggers[k]
.map((t) => modifier + ' + ' + t.toUpperCase())
.join(' or ');
});
return mappedTriggers;
};
/**
* Generates the detailed instructions for VoxLens.
* @memberOf utils
* @param {Object} triggers - Triggers for each VoxLens mode.
* @param {string} title - The title of the viz.
* @param {Object} settings - Settings for VoxLens based on the OS.
* @returns {string} - The instructions to interact with VoxLens.
*/
export const getInstructionsText = (triggers, title, settings) => {
const modifier = getModifier(settings);
const mappedTriggers = getMappedTriggers(triggers, modifier, settings);
return `Graph with title: ${title}. To interact with the graph, press ${mappedTriggers.mainKey} all together and in order. You'll hear a beep sound, after which you can ask a question such as what is the average or what is the maximum value in the graph. To hear the textual summary of the graph, press ${mappedTriggers.summaryKey}. To hear the audio graph, press ${mappedTriggers.trendKey}. To repeat these instructions, press ${mappedTriggers.instructionsKey}. Key combinations must be pressed all together and in order.`;
};
/**
* Generates the initial instructions for VoxLens.
* @memberOf utils
* @param {Object} viewportElement - Element that contains the viz.
* @param {Object} triggers - Triggers for each VoxLens mode.
* @param {string} title - The title of the viz.
* @param {Object} settings - Settings for VoxLens based on the OS.
* @returns {string} - The initial instructions for VoxLens.
*/
export const generateInstructions = (
viewportElement,
triggers,
title,
settings
) => {
const modifier = getModifier(settings);
const mappedTriggers = getMappedTriggers(triggers, modifier, settings);
const label = `Graph with title: ${title}. To listen to instructions on how to interact with the graph, press ${mappedTriggers.instructionsKey}. Key combinations must be pressed all together and in order.`;
viewportElement.setAttribute('aria-label', label);
for (let vc of Array.from(viewportElement.children)) {
vc.setAttribute('aria-hidden', true);
}
};
/**
* Creates a temporary element to relay response to screen readers.
* @memberOf utils
* @param {string} text - The response to relay to the screen reader.
* @param {Object} options - Time after which the element is automatically removed from the DOM tree.
*/
export const createTemporaryElement = (text, options) => {
const name = options.name || 'voxlens-response';
const existingElement = document.getElementsByName(name)[0];
if (existingElement) existingElement.remove();
const div = document.createElement('div');
if (options.stopElement || !options.debug)
div.setAttribute('class', 'hidden');
div.setAttribute('name', name);
div.setAttribute('aria-live', 'assertive');
if (!os.name.includes('Mac OS')) {
div.setAttribute('role', 'alert');
}
options.element.parentElement.appendChild(div);
div.innerHTML = text;
};
/**
* Converts an object into an array.
* @memberOf utils
* @param {Object[]} data - The raw json data used to create the viz.
* @param {string} key - The key to extract values from.
* @return {string[]} - An array of values from a given key.
*/
export const getArrayFromObject = (data, key) =>
Array.isArray(key)
? key.map((k) => data.map((d) => d[k]))
: data.map((d) => d[key]);
/**
* Validates the data supplied to VoxLens and throws errors where necessary.
* @memberOf utils
* @param {Object[]} data - The raw json data used to create the viz.
* @param {Object} options - The original options supplied to voxlens before defaults are applied.
*/
export const validate = (data, options) => {
if (isEmpty(options.x)) {
throw new TypeError('Independent variable not set.');
} else if (isEmpty(options.y)) {
throw new TypeError('Dependent variable not set.');
} else if (isEmpty(data) || !data.every(isNumber)) {
throw new TypeError(
'Dependent variable values are missing or not numeric.'
);
} else if (isEmpty(options.title)) {
throw new TypeError('Title not set.');
}
};
/**
* Adds thousands separators for large numbers.
* @memberOf utils
* @param {number} value - The value to add separators to.
* @returns {string} - The value with thousands separators.
*/
export const addThousandsSeparators = (value) => {
value = round(value, 2).toString();
value = value.replace(',', '');
value = value.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return value;
};
/**
* Formats the options, specifically the xLabel and yLabel.
* @memberOf utils
* @param {Object} options - The options supplied to voxlens when creating the viz.
* @param {string} options.xLabel - Label for the x-axis.
* @param {number} options.yLabel - Label for the y-axis.
* @return {Object} - The formatted options.
*/
export const formatOptions = (options) => {
const xLabel = Array.isArray(options.xLabel)
? options.xLabel.reverse().join(' and ')
: options.xLabel;
return {
...options,
xLabel: startCase(xLabel),
yLabel: startCase(options.yLabel),
};
};
/**
* Finds settings based on the operating system.
* @memberOf utils
* @return {Object} - The settings based on the user's operating system.
*/
export const getSettings = () => {
if (os.name.includes('Mac OS')) {
return settings.MacOS;
} else if (os.name.includes('Windows')) {
return settings.Windows;
} else {
return settings.default;
}
};
/**
* Generates the final response by adding feedback and commands to it.
* @memberOf utils
* @param {string} response - The response from commands.
* @param {string} voiceText - Query issued by the user.
* @return {string} - The response relayed to the user's screen reader.
*/
export const addFeedbackToResponse = (response, voiceText) => {
response = response.replace(/ +(?= )/g, '');
return `${getFeedbackText()} ${voiceText}. ${response}`;
};
/**
* Verbalises an array of values by joining each value and adding "and" before the last one.
* @memberOf utils
* @param {string[]} values - The array of values to be verbalised.
* @return {string} - The verbalised response from the values.
*/
export const verbalise = (values) => {
const total = values.length;
if (values.length > 1) {
values[total - 1] = `and ${values[total - 1]}`;
values = values.join(', ');
} else {
values = values[0];
}
return values;
};
/**
* Generates key bindings for a given set of combinations.
* @memberOf utils
* @param {string} listeningKeys - Listening keys supported by VoxLens.
* @param {string[]} combinations - Different combinations to activate a given mode.
* @return {string} - The key bindings for the "hotkeys" library.
*/
export const getKeyBinds = (listeningKeys, combinations) =>
combinations.map((c) => listeningKeys + '+' + c).join(',');
/**
* Logs key presses into the user's local storage.
* @memberOf utils
* @param {string} listeningKeys - Listening keys supported by VoxLens.
* @param {Object} event - Keypress event object.
*/
export const logKeyPresses = (listeningKeys, event) => {
const key = getKeyFromEvent(event);
const combination = listeningKeys + '+' + key;
console.log('[VoxLens] Key combination issued: ' + combination);
let keyCombinationsPressed =
window.localStorage.getItem('keyCombinationsPressed') || '[]';
keyCombinationsPressed = JSON.parse(keyCombinationsPressed);
keyCombinationsPressed.push({ combination, time: Date.now() });
window.localStorage.setItem(
'keyCombinationsPressed',
JSON.stringify(keyCombinationsPressed)
);
};
/**
* Logs command issued into the user's local storage.
* @memberOf utils
* @param {string} command - Command issued using VoxLens.
* @param {string} response - Response generated by VoxLens.
*/
export const logCommand = (command, response) => {
if (command && command.trim() !== '')
console.log('[VoxLens] Command issued: ' + command);
let commandsIssued = window.localStorage.getItem('commandsIssued') || '[]';
commandsIssued = JSON.parse(commandsIssued);
commandsIssued.push({ command, response, time: Date.now() });
window.localStorage.setItem('commandsIssued', JSON.stringify(commandsIssued));
};
/**
* Converts the event code into the face value of the key.
* @memberOf utils
* @param {Object} event - Keypress event object.
* @return {string} - The face value of the pressed key.
*/
export const getKeyFromEvent = (event) =>
event.code.toLowerCase().replace('key', '').replace('digit', '');
/**
* Checks to see if the command issued is duplicate.
* @memberOf utils
* @param {Object} lastIssuedCommand - Details about the last issued command.
* @param {Object[]} activatedCommands - List of commands issued by the user.
* @return {boolean} - True if the command is duplicate and false otherwise.
*/
export const isCommandDuplicate = (lastIssuedCommand, activatedCommands) => {
const timeNow = Date.now();
const isCommandSameAsLast =
lastIssuedCommand.command &&
activatedCommands.length === 1 &&
lastIssuedCommand.command.includes(activatedCommands[0].name);
if (isCommandSameAsLast) {
const timeDifferenceFromLastCommand =
timeNow - (lastIssuedCommand.time || 0);
if (timeDifferenceFromLastCommand < 1000) return true;
}
return false;
};
/**
* Sanitizes the voice input by removing stop words and converting words to numbers.
* @memberOf utils
* @param {Object} voiceText - Voice input from the user.
* @return {string} - The sanitized voice input query.
*/
export const sanitizeVoiceText = (voiceText = '') => {
voiceText = voiceText.replace(/(\d+)(st|nd|rd|th)/, '$1');
voiceText = voiceText.replaceAll("'s", '');
voiceText = voiceText
.split(' ')
.filter(
(v) =>
(Number.isInteger(parseInt(wordsToNumbers(v))) ||
v.trim().length > 2) &&
!stopWords.includes(v)
)
.join(' ')
.trim();
return voiceText;
};
export const performFuzzySearch = (data, voiceText) => {
const haystack = data.map((e) => e.toString().toLowerCase());
let results = [];
let result = {};
let maxScore = 0;
uniq(voiceText).forEach((needle) => {
let [fuzzyresults] = fuzzy.search(haystack, needle.toString());
fuzzyresults.forEach((i) => {
const value = data[i];
let score = 1;
if (result[value]) score = result[value] + 1;
result[value] = score;
if (score > maxScore) maxScore = score;
});
Object.keys(result).forEach((r) => {
results.push({
score: result[r],
value: r,
});
});
});
results = results.filter((v) => v.score === maxScore).map((v) => v.value);
const indices = data
.map((v, i) => ({ v: v.toString(), i }))
.filter((v) => results.includes(v.v))
.map((v) => v.i);
return indices;
};
export const speakResponse = (text, options) => {
if (options.debug && options.debug?.responses?.onlyText !== true) {
options.speaker.stop();
options.speaker.speak(text);
}
};
/**
* Set of stop words
* @memberOf utils
*/
const stopWords = [
'a',
'able',
'about',
'across',
'after',
'all',
'almost',
'also',
'am',
'among',
'an',
'and',
'any',
'are',
'as',
'at',
'be',
'because',
'been',
'but',
'by',
'can',
'cannot',
'could',
'dear',
'did',
'do',
'does',
'either',
'else',
'ever',
'every',
'for',
'from',
'get',
'got',
'had',
'has',
'have',
'he',
'her',
'hers',
'him',
'his',
'how',
'however',
'i',
'if',
'in',
'into',
'is',
'it',
'its',
'just',
'let',
'like',
'likely',
'may',
'me',
'might',
'must',
'my',
'neither',
'no',
'nor',
'not',
'of',
'off',
'often',
'on',
'only',
'or',
'other',
'our',
'own',
'rather',
'said',
'say',
'says',
'she',
'should',
'since',
'so',
'some',
'than',
'that',
'the',
'their',
'them',
'then',
'there',
'these',
'they',
'this',
'tis',
'to',
'too',
'twas',
'us',
'wants',
'was',
'we',
'were',
'what',
'when',
'where',
'which',
'while',
'who',
'whom',
'why',
'will',
'with',
'would',
'yet',
'you',
'your',
"ain't",
"aren't",
"can't",
"could've",
"couldn't",
"didn't",
"doesn't",
"don't",
"hasn't",
"he'd",
"he'll",
"he's",
"how'd",
"how'll",
"how's",
"i'd",
"i'll",
"i'm",
"i've",
"isn't",
"it's",
"might've",
"mightn't",
"must've",
"mustn't",
"shan't",
"she'd",
"she'll",
"she's",
"should've",
"shouldn't",
"that'll",
"that's",
"there's",
"they'd",
"they'll",
"they're",
"they've",
"wasn't",
"we'd",
"we'll",
"we're",
"weren't",
"what'd",
"what's",
"when'd",
"when'll",
"when's",
"where'd",
"where'll",
"where's",
"who'd",
"who'll",
"who's",
"why'd",
"why'll",
"why's",
"won't",
"would've",
"wouldn't",
"you'd",
"you'll",
"you're",
"you've",
];
/**
* Computes CV from metadata for relaying uncertainty information.
* @memberOf utils
* @param {Object} metadata - Object with min, max, stdev, isAverage.
* @param {number} value - Value of the data point.
* @return {Object} - The metadata with CV information.
*/
export const computeMetadata = (metadata, value) => {
if (metadata.stdev != null && value > 0) {
metadata.cv = metadata.stdev / value;
}
return metadata;
};
/**
* Adds CV information to the data.
* @memberOf utils
* @param {Object} data - Object with data values.
* @return {Object} - The modified data with CV and percentile threshold information.
*/
export const addVariationInformation = (data) => {
const cvs = data.map((d) => d['vx_metadata'].cv);
const percentileThreshold = 0.5;
const percentileLimit = stats.percentile(cvs, percentileThreshold);
return data.map((d) => ({
...d,
vx_metadata: {
...d['vx_metadata'],
isCVHigh: d['vx_metadata'].cv >= percentileLimit,
percentileThreshold: percentileThreshold * 100,
},
}));
};