/**
*
* @class The DataAdapter class is responsible for handling data normalization and transformation,
* primarily used in analyzing project data across multiple aggregators.
* It performs various operations on response data, such as sorting, aggregating, and calculating
* statistics like proportions, z-tests, and confidence levels.
*
* @copyright (c) 2024 TLF Research Ltd.
*
*/
class DataAdapter {
// Contains info about all the Projects accessible to the currently logged-in user.
#projects;
// Contains info about the various aggregators supported by the system
#aggrMeta;
// Original API response
#resp;
// Aggregator metadata
#meta;
// Internal data store that has been normalised to a standard format
#data;
// Array listing any omitted series (because of a low base)
#omitted;
/**
* Returns a deep clone of the given object.
*
* @static
* @param {*} orig - The object to be cloned
* @return {*} A clone of the given object
*/
static deepClone(obj, track = new WeakMap()) {
// Return non-objects or null directly (primitives and functions)
if (Object(obj) !== obj || obj instanceof Function) {
return obj;
}
// Avoid circular references using a WeakMap
if (track.has(obj)) {
return track.get(obj);
}
// Handle array cloning
if (Array.isArray(obj)) {
const arrClone = [];
track.set(obj, arrClone); // Set the original array in 'track'
obj.forEach((item, index) => {
arrClone[index] = DataAdapter.deepClone(item, track); // Recursively clone each item
});
return arrClone;
}
// Handle object cloning
const clone = {};
track.set(obj, clone); // Set the original object in 'track'
Object.keys(obj).forEach((key) => {
clone[key] = DataAdapter.deepClone(obj[key], track); // Recursively clone each property
});
// Copy over non-enumerable symbols if present
Object.getOwnPropertySymbols(obj).forEach((sym) => {
clone[sym] = DataAdapter.deepClone(obj[sym], track);
});
return clone;
}
/**
* Returns a deep merge of the second object into the first and reurns the result.
*
* @static
* @param {*} obj1 - The base object (that is changed in place)
* @param {*} obj2 - The object to be merged
*
* @return {*} A deep merge of the given objects aka obj1
*/
static deepMerge(obj1, obj2, track = new WeakMap()) {
// Return non-objects directly
if (Object(obj2) !== obj2) {
return obj2;
}
// Return functions bound to the base object
if (obj2 instanceof Function) {
return obj2.bind(obj1);
}
// Avoid circular references using WeakMap
if (track.has(obj2)) {
return track.get(obj2);
}
// Handle arrays
if (Array.isArray(obj2)) {
if (!Array.isArray(obj1)) {
obj1 = []; // Make sure obj1 is an array if obj2 is an array
}
track.set(obj2, obj1); // Track obj2 to prevent circular refs
obj2.forEach((item, index) => {
obj1[index] = DataAdapter.deepMerge(obj1[index], item, track); // Recursively merge items
});
return obj1;
}
// Handle objects
if (Object(obj1) !== obj1 || Array.isArray(obj1)) {
obj1 = {}; // Make sure obj1 is an object if it isn't already
}
track.set(obj2, obj1); // Track obj2 to avoid circular references
Object.keys(obj2).forEach((key) => {
obj1[key] = DataAdapter.deepMerge(obj1[key], obj2[key], track); // Recursively merge properties
});
// Merge symbols if present
Object.getOwnPropertySymbols(obj2).forEach((sym) => {
obj1[sym] = DataAdapter.deepMerge(obj1[sym], obj2[sym], track); // Recursively merge symbol properties
});
return obj1;
}
/**
* Performs a z-test on two independent samples.
*
* @static
* @param {Object} sample1 - First sample with `count` and `base` properties.
* @param {Object} sample2 - Second sample with `count` and `base` properties.
* @returns {number} - Returns 1 if the difference is significant, -1 if inverse, and 0 if not significant.
*/
static zTest(sample1, sample2) {
// Ignore null values
if (sample1.count === null || sample1.base === null ||
sample2.count === null || sample2.base === null) {
return 0;
}
// Ensure the inputs are valid
if (sample1.base <= 0 || sample2.base <= 0) {
console.error('Base must be greater than 0 for both samples');
return 0;
}
// Calculate the proportions
const p1 = sample1.count / sample1.base;
const p2 = sample2.count / sample2.base;
// Skip if both proportions are identical
const epsilon = 1e-10;
if (Math.abs(p1 - p2) < epsilon) {
return 0;
}
// Calculate the combined proportion
const p12 = (sample1.count + sample2.count) / (sample1.base + sample2.base);
// Calculate the standard error
const standardError = Math.sqrt(p12 * (1 - p12) * (1 / sample1.base + 1 / sample2.base));
if (standardError === 0) {
console.error('Standard error is 0, which implies that the given sample may be insufficient for a z-test');
return 0;
}
// Calculate the t statistic (which is equivalent to z in this case)
const t = (p2 - p1) / standardError;
// For simplicity, we're using the normal distribution approximation
// In a real-world scenario, you might want to use a t-distribution lookup table or function
if (Math.abs(t) > 1.96) {
return t > 0 ? 1 : -1;
}
return 0;
}
/**
* Performs a z-test where the second sample is a subset of the first.
*
* @static
* @param {Object} overall - Overall sample with `count` and `base` properties.
* @param {Object} subset - Subset sample with `count` and `base` properties.
* @returns {number} - Returns 1 if the difference is significant, -1 if inverse, and 0 if not significant.
*/
static zTestOverall(overall, subset) {
// Ignore null values
if (overall.count === null || overall.base === null ||
subset.count === null || subset.base === null) {
return 0;
}
// Ignore small samples
if (overall.base < 30 || subset.base < 30) {
return 0;
}
// Ensure the inputs are valid
if (overall.base <= 0 || subset.base <= 0 || subset.base > overall.base) {
console.error('Invalid base values');
return 0;
}
// Calculate the proportions
const p1 = overall.count / overall.base;
const p2 = subset.count / subset.base;
// Skip if both proportions are identical
const epsilon = 1e-10;
if (Math.abs(p1 - p2) < epsilon) {
return 0;
}
// Adjust the standard error to account for overlapping samples
const standardError = Math.sqrt(p1 * (1 - p1) * (1 / subset.base - 1 / overall.base));
if (standardError === 0) {
console.error('Standard error is 0, which implies that the given sample may be insufficient for a z-test');
return 0;
}
// Calculate the z statistic
const z = (p2 - p1) / standardError;
// Use the critical value for a 95% confidence interval (~1.96 for two-tailed test)
if (Math.abs(z) > 1.96) {
return z > 0 ? 1 : -1;
}
return 0;
}
/**
* Sorts all data series using the specified series as the reference.
*
* @static
* @param {Object} ds - Dataset object containing metadata.
* @param {Array} data - Array of data series to be sorted.
* @param {string} seriesName - The name of the series to be used for sorting.
* @param {string} [order='ASC'] - The order of sorting, either 'ASC' or 'DESC'.
* @returns {Array} - Sorted data series array.
*/
static sortUsingSeries(ds, data, seriesName, order = 'ASC') {
// Find the series that is going to be used to determine the overall order
const seriesIdx = data.findIndex(d => d.name === seriesName);
if (seriesIdx === -1) {
console.error(`Series '${seriesName}' not found - cannot sort`);
return;
}
// Calculate the indexes needed to access the 'values' array in the desired order
const sortedValues = data[seriesIdx].values.toSorted((a, b) => order === 'ASC' ? a - b : b - a);
const sortIndexes = DataAdapter.#getSortedIndexes(data[seriesIdx].values, sortedValues, order);
// Sort all series into the desired order using the calculated indexes.
// We can do this because these series have already been normalised, so they are the same shape.
data.forEach(d => {
d.labels = DataAdapter.#sortByIndexes(d.labels, sortIndexes);
d.values = DataAdapter.#sortByIndexes(d.values, sortIndexes);
d.count = DataAdapter.#sortByIndexes(d.count, sortIndexes);
});
return data;
}
/**
* Returns the top box score for the given satisfaction scale data.
*
* @static
* @param {Object} ds - Dataset object.
* @param {Object} data - Data object containing satisfaction scores.
* @returns {Number} - Top box percentage as a float.
*/
static getVsatTopBoxPercent(ds, data) {
if (!data || !data.values) {
return 0;
}
const verySatisfiedIdx = ds.categoryOrder.indexOf('Very satisfied');
const fairlySatisfiedIdx = ds.categoryOrder.indexOf('Fairly satisfied');
if (verySatisfiedIdx === -1 || fairlySatisfiedIdx === -1) {
console.error("Missing either 'Very satisfied' or 'Fairly satisfied' in categoryOrder");
return 0;
}
const topbox = ((data.values[verySatisfiedIdx] + data.values[fairlySatisfiedIdx]) * 100).toFixed(1);
return topbox;
};
/**
* Converts verbal scale responses into a format for presentation in charts.
*
* @static
* @param {Object} ds - Dataset object.
* @param {Array} data - Array of data series containing verbal scale responses.
* @returns {Array} - Transformed data array for charts.
*/
static transposeVerbalScaleResponses = (_, data) => {
return DataAdapter.VERBAL_SAT_SCALE.map(chartLabel => {
// Different questions use different verbal scales so we have to map them
// to the standard "L5", "L4" etc. levels before we can look for them.
const level = server.Globals.responses[chartLabel.toLowerCase()];
const dataLabels = [];
const dataValues = [];
const dataCount = [];
const dataTypes = [];
data.forEach(d => {
const lblIndex = d.labels.map(lbl => server.Globals.responses[lbl.toLowerCase()]).indexOf(level);
if (lblIndex !== -1) {
dataLabels.push(`${d.name} (${d.total.toLocaleString()})`);
dataValues.push(d.values[lblIndex]);
dataCount.push(d.count[lblIndex]);
dataTypes.push(this.#getScaleType(d.labels));
} else {
dataLabels.push(`${d.name} (${d.total.toLocaleString()})`);
dataValues.push(0);
dataCount.push(0);
dataTypes.push(this.#getScaleType(d.labels));
}
});
const transposed = {
name: chartLabel,
types: dataTypes.reverse(),
labels: dataLabels.reverse(),
values: dataValues.reverse(),
count: dataCount.reverse()
}
return transposed;
});
};
/**
* Groups the given data into High/Neutral/Low categories.
*
* @static
* @param {Object} ds - Dataset object.
* @param {Object} data - Data object containing satisfaction scores.
* @returns {string} - Top box percentage as a string.
*/
static groupIntoHightNeutralLow = (_, data) => [
{
name: data[0].name,
labels: ["High", "Neutral", "Low"],
values: [data[0].high?.[0], data[0].neutral?.[0], data[0].low?.[0]],
},
];
/**
* calculateHighValuePercentages returns the percentage of high values as a proportion of the total responses.
*
* @static
* @param {Array} data - Array of data series containing high values.
* @returns {Array} - Transformed data array for charts.
*/
static calculateHighValuePercentages = data => {
data.forEach((_, idx) => {
data[idx].values = data[idx].high.map((_, idxHi) => data[idx].high[idxHi] / data[idx].total[idxHi]);
});
return data;
};
/**
* convertVerbalScaleToLevels converts all the different verbal scale responses into L1 - L5 categories
*
* @static
* @param {Array} data - Array of data series containing verbal scale responses.
* @returns {Array} - Transformed data array for charts.
*/
static convertVerbalScaleToLevels = data => {
if (!data || data.length === 0) return data;
const names = ["L5", "L4", "L3", "L2", "L1"];
const labels = data.map(d => d.name).reverse();
const transformed = names.map(name => ({
name,
labels,
values: data
.map(d => {
// Find the index of the 'Lx' category response and return its value
const respIdx = d.labels.findIndex(lbl => server.Globals.responses[lbl.toLowerCase()] === name);
return respIdx !== -1 ? d.values[respIdx] : null;
})
.reverse(),
}));
// Move any general 'Ease' category to the end (will be shown first)
const easeIdx = labels.indexOf("Ease");
if (easeIdx === -1) return transformed;
// Only need to reorder the labels array once as all the elements in transformed share it
const lbl = transformed[0].labels.splice(easeIdx, 1)[0];
transformed[0].labels.push(lbl);
transformed.forEach((_, idx) => {
const val = transformed[idx].values.splice(easeIdx, 1)[0];
transformed[idx].values.push(val);
});
return transformed;
};
/**
* Gets the difference between the two sets of data.
*
* @static
* @param {Object} ds - Dataset object.
* @param {Array} data - Array of data series containing verbal scale responses.
* @returns {Array} - Transformed data array for charts.
*/
static convertToDelta = (ds, data) => {
if (data.length !== 2) {
console.error("convertToDelta requires a data array with exactly 2 elements");
return [];
}
// Each set of values may represent data for different splits, so we have to match them up.
// Pick the right series to show
const showSeries = ds.showLastSeriesLabels ? data[data.length - 1] : data[0];
// Omit any series labels that are of type _PLACEHOLDER
const showLabels = showSeries.labels.reduce((arr, lbl, idx) => {
if (showSeries.types[idx] !== '_PLACEHOLDER') arr.push(lbl);
return arr;
}, []);
// Fold all the labels into lower case for comparison
const findLabels = showLabels.map(lbl => lbl.toLowerCase());
const data0Labels = data[0].labels.map(lbl => lbl.toLowerCase());
const data1Labels = data[1].labels.map(lbl => lbl.toLowerCase());
// Find the difference between the two sets
const deltas = findLabels.map(lbl => {
const idx0 = data0Labels.indexOf(lbl);
const val0 = idx0 === -1 ? 0 : data[0].values[idx0];
const idx1 = data1Labels.indexOf(lbl);
const val1 = idx1 === -1 ? 0 : data[1].values[idx1];
return val0 - val1;
})
const delta = [
{
name: "Delta",
labels: showLabels,
values: deltas,
},
];
return delta;
};
/**
* multiplyBy is a higher-order function that returns a function that mutiplies
* all the data values by the given multiplier.
*
* @static
* @param {number} multiplier - The multiplier to be applied to the data values
* @returns {Function} - A function that mutiplies all the data values by the given multiplier
*/
static multiplyBy(multiplier) {
return (_, data) => {
if (!Array.isArray(data)) data = [data];
data.forEach((_, idx) => {
data[idx].values = data[idx].values.map(v => v ? v * multiplier : v);
});
return data;
};
}
/**
* getTSMSubmissionTable returns data for a table of TSM submissions.
*
* @static
* @param {Object} ds - Data source object.
* @param {Array} data - Array of data series containing TSM responses.
*/
static getTSMSubmissionTable(ds, data) {
// Bail if no data
if (!data || data.length === 0) return [];
// Find which data indexes the TP 'satisfaction' and 'agree' scales start from
const satStartsAt = data[0].labels.indexOf('very satisfied');
const agreeStartsAt = data[0].labels.indexOf('strongly agree');
// Combine the data from the above series into a new set of data that's been reconfigured
// as columns of data per TP variable.
const newData = [];
// Create mapping between the TP variable numbers and the corresponding index in the data array
// It should account for any indexs that are omitted.
const tpVarToDataIndex = ds.series.reduce((arr, s, idx) => {
if (s.variable.startsWith('tp')) {
const tpVarNum = Number(s.variable.substring(2, 4));
if (tpVarNum) arr[tpVarNum] = idx;
}
return arr;
}, []);
// Process each TP variable
for (let tpVar = 1; tpVar <= 12; tpVar++) {
// Skip any unwanted variables
if (typeof tpVarToDataIndex[tpVar] === 'undefined') {
continue;
}
// Each column needs 10 rows
const values = Array(10).fill(' ');
const types = Array(10).fill(tpVar === 8 ? 'Agree' : 'Satisfaction');
// Add Yes/No bases for some questions
switch (tpVar) {
case 2:
case 3:
values[0] = data[0].yes.toLocaleString();
values[1] = data[0].no.toLocaleString();
break;
case 9:
values[0] = data[1].yes !== null ? data[1].yes.toLocaleString() : 'n/a';
values[1] = data[1].no !== null ? data[1].no.toLocaleString() : 'n/a';
break;
case 10:
values[0] = data[2].yes !== null ? data[2].yes.toLocaleString() : 'n/a';
values[1] = data[2].yes !== null ? data[2].no.toLocaleString() : 'n/a';
break;
}
const dataIdx = tpVarToDataIndex[tpVar];
// Add sat scores (and total base) for all questions
let valueOffset = 0;
for (let satIdx = 3; satIdx <= 8; satIdx++) {
const val = satIdx === 8 ? data[dataIdx].total - (data[dataIdx].dontknow || 0) : getSatValue(tpVar, data[dataIdx], valueOffset);
if (!val) {
console.error('No sat value found for TP' + String(tpVar).padStart(2, '0'));
if (tpVar === 8) console.error(`Are the scale values correct in the table? (i.e. an 'Agree' scale)`);
}
values[satIdx] = val ? val.toLocaleString() : 'n/a';
valueOffset++;
}
// Add n/a count (for some questions)
switch (tpVar) {
case 5:
case 6:
case 7:
case 8:
case 11:
case 12:
values[9] = data[dataIdx].dontknow;
break;
default:
values[9] = '-';
break;
}
newData.push({ values, types });
}
return newData;
function getSatValue(tpVar, varData, valueOffset) {
// TP08 is an 'agree'-type scale.
return tpVar === 8 ? varData.values[agreeStartsAt + valueOffset] : varData.values[satStartsAt + valueOffset];
}
}
/**
* Constructs a DataAdapter instance.
* @param {Array} projects - Array of available projects for the current user.
* @param {Array} aggrMeta - Metadata about aggregators supported by the system.
*/
constructor(projects, aggrMeta) {
this.#projects = projects;
this.#aggrMeta = aggrMeta;
this.#resp = {};
this.#meta = null;
this.#data = {};
this.#omitted = [];
this.label = null; // Provides an (optional) label.
this.reverse = false; // When true, the categories and their associated values are reversed.
this.removeEmptyCategories = false; // When true, categories with a zero value are removed.
this.removeInapplicableResponses = false; // When true, inapplicable i.e. 'n/a', 'don't know' etc. responses are removed from the data.
this.convertToProportions = false; // When true, raw data values are converted to a proportion [0,1] of the total.
this.combineResults = false; // When true, the results are combined into a single series.
this.categoryOrder = []; // Defines the order in which category data should be returned.
this.dependentVar = null; // Defines the dependent variable when calculating a correlation.
this.showCount = false; // When true appends the sample count to the end of each category.
this.addOverall = false; // When true an 'Overall' category has been added to the data.
this.lowBase = 0; // Data points with fewer responses than this are moved to the 'lowBase' key of the adapter's output.
this.nonStandardCategories = []; // Identifies categories that should be treated as non-standard i.e. not a satisfation scale.
this.byQuarter = false; // When true, calendar data is grouped by quarter.
}
/**
* Gets the API response data in the adapter.
* @returns {Object} - API response object containing data to be processed.
*/
get response() {
return this.#resp;
}
/**
* Sets the API response data in the adapter.
* Normalizes the data based on the aggregator metadata.
* @param {Object} r - API response object containing data to be processed.
*/
set response(r) {
this.#resp = r;
// Check that we recognize this response's aggregator (defined in the response's name)
if (r.name) {
this.#meta = this.#aggrMeta.find(aggr => aggr.function === r.name);
if (!this.#meta) {
console.error(`Unexpected aggregator '${r.name}' - skipping`);
this.#data = {};
return;
}
const { data, omitted } = this.#normalize(r);
this.#data = data;
this.#omitted = omitted;
return;
}
this.#data = r.rows;
}
/**
* Gets the output of the adapter after transforming the data.
* @returns {Array} - Transformed data ready for consumption (e.g., for display).
*/
get output() {
return this.#transform();
}
/**
* Gets the normalized data store.
* @returns {Object} - Normalized data object.
*/
get data() {
return this.#data;
}
/**
* Gets the omitted data series.
* @returns {Array} - Array of omitted data series due to low base or other constraints.
*/
get omitted() {
return this.#omitted || [];
}
/**
* Calculates the impact of independent variables on the dependent variable.
* @param {Object} ds - Dataset information object.
* @param {Object} data - Raw data used for calculation.
* @returns {Array} - Array of calculated impact results.
*/
calculateImpact(ds, data) {
if (data.length === 0 || data[0].length === 0) {
return [];
}
const rows = data[0];
// Count the non-null values for each variable
// All the response rowws have the same shape so we pick the first one to get the variable names
const varNames = Object.keys(rows[0]);
const isNumericScale = varNames.every(v => rows[0][v] === null || !isNaN(rows[0][v]));
const validResponses = isNumericScale ? [1, 2, 3, 4, 5] : [...DataAdapter.VERBAL_SAT_SCALE, ...DataAdapter.VERBAL_AGREE_SCALE];
const bases = rows.reduce((b, row) => {
varNames.forEach(v => {
b[v] = (b[v] || 0) + (validResponses.includes(row[v]) ? 1 : 0);
});
return b;
}, {});
const { _, corr } = this.#calculateCorrelations(data);
// Order in descending order of impact
corr.sort((a, b) => b.corr - a.corr);
const labels = corr.map(c => c.label + (this.showCount ? ` (${bases[c.name].toLocaleString()})` : ""));
const values = corr.map(c => c.corr);
const names = corr.map(c => c.name);
const types = names.map(name => this.#getConfiguredScaleType(name));
if (this.reverse) {
labels.reverse();
values.reverse();
types.reverse();
}
return [
{
project: this.#resp?.project,
name: ds.label || "Importance",
labels,
values,
types
},
];
}
/**
* Processes text analysis data from Amazon Comprehend for the given dataset.
* @param {Object} ds - Dataset information object.
* @param {Array} data - Raw data array for text analysis.
* @param {number} seriesIdx - Index of the series in the dataset.
* @param {Array<number>} thresholds - Array of thresholds used for filtering themes.
* @returns {Array} - Processed text analysis result for presentation.
*/
processComprehendTextAnalysis(ds, data, seriesIdx, thresholds) {
// Bail if no data
if (!data || !data.length) {
console.error('processComprehendTextAnalysis - no data to process');
return [];
}
// Bail if no thresholds, or if the placeholder hasn't been replaced with anything
if (!thresholds || !Array.isArray(thresholds) || !thresholds.length) {
console.error('processComprehendTextAnalysis - no score thresholds specified - check config');
return [];
}
// Get the variable name from the appropriate series
const varName = ds.series[seriesIdx].variable;
// Bail if no analysis is present i.e. there is no data for this variable
if (!data[0][varName]) {
console.error('processComprehendTextAnalysis - no data for variable', varName);
return [];
}
let totalComments = 0;
// Process each raw response row
const stats = data.reduce((s, row, idx) => {
try {
const themes = JSON.parse(row[varName]);
// Count all the comment rows whose main theme is not the '-' placeholder
if (themes[0].name !== '-') totalComments++;
// If the first theme is for an inapplicable value (i.e. 'n/a', 'don't know' etc.) then
// process it, but ignore any other themes in this record.
const firstThemeIsNotApplicable = DataAdapter.VERBAL_INAPPLICABLE.includes(themes[0].name);
themes.forEach((theme, idx) => {
const isNotApplicable = DataAdapter.VERBAL_INAPPLICABLE.includes(theme.name);
const ignoreThisTheme = idx !== 0 && (isNotApplicable || firstThemeIsNotApplicable);
if (!ignoreThisTheme && theme.score > thresholds[idx] && theme.name !== '-') {
s[theme.name] = s[theme.name] || 0;
s[theme.name]++;
}
});
} catch (e) {
console.error('Failed to parse JSON on row', idx, e);
}
return s;
}, {});
const counts = Object.values(stats);
const labels = Object.keys(stats);
const rawCategories = [...labels];
// If necessary append the counts to each theme's label
if (this.showCount) {
labels.forEach((_, idx) => {
if (counts[idx]) {
labels[idx] += ` (${counts[idx].toLocaleString()})`;
}
});
}
if (this.reverse) {
counts.reverse();
labels.reverse();
rawCategories.reverse();
}
const values = this.convertToProportions ? counts.map(c => c / totalComments) : counts;
return [
{
project: this.#resp?.project,
name: ds.series[seriesIdx].label,
labels,
count: counts,
total: totalComments,
values,
rawCategories
},
];
}
// #getConfiguredScaleType returns the configured scale type for the given data name
#getConfiguredScaleType(name) {
// Compare the start of the data name with each of the nonStandardCategories
const keys = Object.keys(this.nonStandardCategories);
const nonStandard = keys.findIndex(k => {
if (name.toLowerCase().startsWith(k.toLowerCase())) return true;
return false;
});
return nonStandard == -1 ? "Satisfaction" : this.nonStandardCategories[keys[nonStandard]];
};
// #getDataFields returns information about the shape of the given aggregator's response
#getDataFields(resp, vName) {
const origFields = DataAdapter.#aggrDataFields[resp.name];
const fields = Object.keys(origFields).reduce((obj, k) => {
obj[k] = origFields[k];
return obj;
}, {});
if (!fields) {
console.error(`No field data for the '${resp.name}' aggregator - skipping`);
return {};
}
// Replace any placeholders
Object.keys(fields).map(k => {
if (typeof fields[k] === 'string') fields[k] = this.#replacePlaceholders(fields[k], resp, vName);
if (Array.isArray(fields[k])) {
fields[k] = fields[k].map(f => this.#replacePlaceholders(f, resp, vName)).filter(v => v);
if (fields[k].length === 1) fields[k] = fields[k][0];
}
});
return fields;
};
// #normalize converts raw response data from the API into a standard internal format,
// It returns an object like this:
// {
// varName1: { category: [category Array], value: [value Array], count: [ count Array ], sd: [ sample std dev array ], sdp: [ pop std dev array], total: [ total array ] },
// varName2: { category: [category Array], value: [value Array], count: [ count Array ], total: [ total array ] },
// }
//
// NOTE: Not all aggregators will return all possible keys.
#normalize(resp) {
const vars = resp.vars.split(",");
// Make sure that the response is consistent with what we know about the endpoint
if (vars.length > 1 && !this.#meta.multi) {
console.error(
`The '${resp.name}' aggregator does not support multiple variables, but the API's "vars" key contains more than one variable`
);
return [];
}
const proj = this.#projects.find(p => p.id === resp.project);
let normalized;
if (this.#meta.multi) {
// Multi-variable endpoints have rows containing data for each variable
normalized = vars.reduce((outerObj, vName) => {
// Get all the field names for this aggregator and variable.
const variable = proj.vars[vName];
const fields = this.#getDataFields(resp, vName);
outerObj[vName] = { aggr: resp.name, v: variable, group: resp.group, ...this.#getFieldValuesFromRows(vName, fields, resp.group, resp) };
return outerObj;
}, {});
} else {
// Single-variable endpoints have multiple rows with data for the same variable
const vName = vars[0];
const variable = proj.vars[vName];
const fields = this.#getDataFields(resp, vName);
normalized = { [vName]: { aggr: resp.name, v: variable, group: resp.group, ...this.#getFieldValuesFromRows(vName, fields, resp.group, resp) } };
}
// Calculate how many values are undefined in each category
const stats = {};
for (const varName in normalized) {
const data = normalized[varName].result;
for (const field in data) {
if (field !== 'category') {
data.category.forEach((cat, cIndex) => {
if (typeof stats[cat] === 'undefined') stats[cat] = { missing: 0, total: 0 };
stats[cat].total++;
if (Array.isArray(data[field]) && data[field][cIndex] === undefined) {
stats[cat].missing++;
}
});
}
}
}
const omitted = [];
const indexesToRemove = [];
// Check to see if any categories are to be removed.
// We only need to check the first variable as the results for each are all the same shape.
for (const varName in normalized) {
const data = normalized[varName].result;
data.category.forEach((cat, idx) => {
// If all values for a category are 'missing' it must be removed
if (stats[cat].missing === stats[cat].total) {
omitted.push(cat);
indexesToRemove.push(idx);
return;
}
});
break;
}
// Remove any omitted array elements across all the data fields with the given indexes
if (indexesToRemove.length) {
for (const varName in normalized) {
const data = normalized[varName].result;
for (const field in data) {
data[field] = data[field].filter((_, idx) => !indexesToRemove.includes(idx));
}
}
}
return { data: normalized, omitted };
}
/**
* #replacePlaceholders replaces the placeholders in the given text with values from the response
* @param {string} text
* @param {Object} resp
* @param {string} vName
* @returns {string}
*/
#replacePlaceholders(text, resp, vName) {
// If field is not a string return it unchanged
if (typeof text !== 'string') return text;
text = text.replace("#VAR_NAME#", vName);
let group = resp.group;
if (group) {
// If the group has one or more aliases then use them as the row value key.
// Regex to find one or more field alias in the form 'col1 AS alias1, EXPRESSION() AS alias2'
const matches = [];
const findAliases = /\bAS\s+(\w+)/gi;
for (const match of group.matchAll(findAliases)) {
matches.push(match[1]);
}
if (matches.length) {
group = matches.length === 1 ? matches[0] : matches.join(',');
}
}
text = text.replace("#GROUP#", group);
return text;
}
// #transform converts the internal data store into a format that is suitable for display
#transform() {
const firstDataKey = Object.keys(this.#data)[0];
// IF we don't have a first data key then there's nothing to transform
if (!firstDataKey) {
console.error('No data to transform');
return [];
};
// If the adapter's data isn't of the form this.#data[key].result then it's the result of getting raw responses rather
// than the result of an aggregator. In that case there is no transform to be done.
if (!this.#data[firstDataKey].result) return [this.#data];
const data = Object.keys(this.#data).map(v => {
const varInfo = this.#data[v].v;
const varData = this.#data[v].result;
const label = this.combineResults ? null : this.label;
// Grouped 'dist[x]' aggregators need special handling
if (this.#data[v].group && (this.#data[v].aggr === 'dist' || this.#data[v].aggr === 'distx')) {
// categoryOrder is always set in this case
varData.category = [...this.categoryOrder];
// The orignal value array is not valid here as it contains all the original counts
// from the grouped categories.
varData.value = [...varData.total];
}
// Sort the results using any categoryOrder
const indexes = DataAdapter.orderIndexes(varData.category, this.categoryOrder);
const result = {
name: label || varInfo?.caption || v,
};
// Copy the 'standard' keys to the result
result.project = this.#resp?.project;
const labels = DataAdapter.orderBy(varData.category, indexes);
const values = DataAdapter.orderBy(varData.value, indexes);
result.labels = this.reverse && labels ? labels.reverse() : labels;
result.values = this.reverse && values ? values.reverse() : values;
result.types = Array(values.length).fill(this.#getConfiguredScaleType(result.name));
// Add the raw category name(s) without any bases. They will be used in powerpoint.js to normalise series that have different shapes.
if (this.combineResults) {
result.rawCategory = (varInfo.caption || varInfo.name).toLowerCase();
} else {
result.rawCategories = [...labels.map(lbl => lbl ? lbl.toLowerCase() : lbl)];
}
// Copy any other data keys for this variable to the result
Object.keys(varData).forEach(k => {
// Skip these as they've already been processed
if (k === "category" || k === "value") return;
if (Array.isArray(varData[k])) {
const keyVals = DataAdapter.orderBy(varData[k], indexes);
result[k] = this.reverse ? keyVals.reverse() : keyVals;
} else {
result[k] = varData[k];
}
});
// Any quarterly labels need to be formatted
if (this.byQuarter)
result.labels = result.labels.map(lbl => {
const [year, quarter] = lbl.split('|');
return `Q${quarter} ${year}`;
});
return result;
});
if (this.showCount) {
data.forEach(d => {
if (d.count) {
// Append the counts to each category label
let total = 0;
d.count.forEach((c, idx) => {
if (c !== null && c !== undefined) {
d.labels[idx] += ` (${d.count[idx].toLocaleString()})`;
total += d.count[idx];
}
});
// If we added an 'Overall' category then divide the total by 2 as we've added it twice above.
if (this.addOverall) total /= 2;
// Append the total count to the series name
d.name += ` (${total.toLocaleString()})`;
}
});
}
if (this.combineResults) {
// If necessary combine the results into a single series
const labels = data.map(d => d.name);
const rawCategories = data.map(d => d.rawCategory);
const values = data.map(d => d.values);
const raw = data.map(d => d.raw);
const bases = data.map(d => d.count);
const names = data.map(d => d.name);
const types = names.map(name => this.#getConfiguredScaleType(name));
const confidences = data.map(d => d.confidence);
if (this.reverse) {
labels.reverse();
rawCategories.reverse();
values.reverse();
raw.reverse();
bases.reverse();
confidences.reverse();
types.reverse();
}
const combined = [
{
project: this.#resp?.project,
name: this.label || "Series 1",
splits: data[0].labels,
rawCategories,
labels,
values,
raw,
bases,
confidences,
types
},
];
// If we've added an 'Overall' category to the data then fish out its count and total (assumed to all be the first series in the data).
if (this.addOverall) {
const overallIdx = data[0].labels.indexOf('Overall');
if (overallIdx !== -1) {
combined[0].count = data[0].count[overallIdx];
combined[0].total = data[0].total[overallIdx];
}
}
return combined;
}
return data;
}
// #calculateProportionsOfTotal converts the given array of numbers into a set of proportions in the range [0, 1]
#calculateProportionsOfTotal(arr) {
const total = arr.reduce((tot, v) => (tot += v), 0);
return arr.map(v => v / total);
}
// #getFieldValuesFromRows extracts the given field values from the given result rows.
#getFieldValuesFromRows(vName, fields, group, resp) {
let rolledUpRowIndex = -1;
const data = Object.keys(fields).reduce((obj, field) => {
let rowFieldHandler = fields[field];
if (field === "category") {
// If no category handler is specified set the category to the name of the given variable
if (!rowFieldHandler) {
obj[field] = [vName];
return obj;
}
}
// If the field handler is a function then ignore it here
if (typeof rowFieldHandler === "function") {
return obj;
}
// If the rowFieldHandler is a CSV then split it into an array
if (typeof rowFieldHandler === "string") {
rowFieldHandler = rowFieldHandler.split(",");
}
const vals = [];
resp.rows.forEach(row => {
let categories = null;
if (!rowFieldHandler) return;
if (field === 'category') {
categories = Array.isArray(rowFieldHandler) ? rowFieldHandler.map(h => row[h]) : row[rowFieldHandler];
}
// If the count is less than the lowBase then change it to 'undefined'
if (field !== "category") {
const base = row[fields.count];
if (base < this.lowBase) {
vals.push(undefined);
return;
}
}
const { rowVal, isRolledUp } = this.#getRowVal(row, categories, field, rowFieldHandler);
if (isRolledUp) rolledUpRowIndex = vals.length;
vals.push(rowVal);
});
obj[field] = vals;
return obj;
}, {});
// If this was a rolled-up result move all of the rolledup results in each data array to the start
if (rolledUpRowIndex !== -1) {
for (const key in data) {
data[key].unshift(data[key].splice(rolledUpRowIndex, 1)[0]);
}
}
const indexesToRemove = [];
// If required, remove any inapplicable responses
if (this.removeInapplicableResponses) {
data.category.forEach((cat, idx) => {
if (DataAdapter.VERBAL_INAPPLICABLE.includes(cat)) {
indexesToRemove.push(idx);
}
});
}
// Record any null responses in a 'didntanswer' category
const nullResponseIdx = data.category.indexOf(null);
if (nullResponseIdx !== -1) {
data.didntanswer = data.value[nullResponseIdx];
}
// If required, remove any null or blank grouped results
data.category.forEach((cat, idx) => {
if (cat === "" || cat === null) {
indexesToRemove.push(idx);
}
});
// Remove the array elements across all the data fields with the given indexes
for (const field in data) {
if (field === "didntanswer") continue;
data[field] = data[field].filter((_, idx) => !indexesToRemove.includes(idx));
}
// Now apply any function-based handlers to the normalized fields
Object.keys(fields).forEach(f => {
const rowFieldHandler = fields[f];
if (typeof rowFieldHandler === "function") data[f] = rowFieldHandler(resp.name, data, group, this.categoryOrder);
});
if (this.convertToProportions) {
switch (resp.name) {
case "vsatsplit":
// If the aggregator is 'vsatsplit' then we calculate the proportion of each individual high / count.
data.value = data.high.map((v, idx) => (data.count[idx] ? v / data.count[idx] : null));
break;
default:
// Otherwise convert each array value into a proportion of the array's total
// i.e each value lies in the interval [0, 1] and the sum of all values = 1.
data.value = this.#calculateProportionsOfTotal(data.value);
}
}
return { result: data };
}
#getRowVal(row, categories, f, key) {
const result = { rowVal: null, isRolledUp: false };
// This only supports a two-level category
if (f === "category" && Array.isArray(categories) && categories.length === 2) {
// If any of the individual categories array values are null then the category is null
if (categories.some(c => c === null || c === "")) {
result.rowVal = null;
} else {
result.rowVal = categories.join('|');
}
return result;
}
// A row with a key called 'date' is converted to a formatted date string
if (key === "date") {
new Date(row[key]).toLocaleDateString("en-GB", { month: "short", year: "2-digit" });
}
let keyVal = row[key];
// If this was the result of a rolled-up query each row will have an "_aggr_${category}" key.
// If this row's key is set to 1 then it's the rolled-up (i.e. overall) result.
if (f === "category" && row[`_aggr_${key}`] === 1) {
result.isRolledUp = true;
keyVal = "Overall";
}
result.rowVal = keyVal;
return result;
}
// orderIndexes returns an array of indexs into the 'orig' array that would produce the order given in the 'desired' array.
// If a desired value starts with a '-' it is positioned at the end. ONLY ONE SUCH NEGATIVE VALUE IS ALLOWED.
// An element called 'Overall' is always ordered to the start.
// The indexes of any elements in 'orig' that are not in 'desired' are returned at the end.
static orderIndexes(orig, desired = null) {
// Short-circuit if we have no desired order
if (desired === null) return orig.map((_, idx) => idx);
const placeholder = String.fromCharCode(65535);
// Make both arrays lowercase so the sort is case-insensitive
const origClone = orig.map(el => el ? el.toLowerCase() : el);
const desiredClone = desired.map(el => el ? el.toLowerCase() : el);
const result = Array(origClone.length);
// If orig contains 'overall' and it's not in in the specified order, then force it to the top
const origOverallIndex = origClone.findIndex(el => el === 'overall');
const desiredDoesntHaveOverall = desiredClone.findIndex(el => el === 'overall' || el === '-overall') === -1;
if (origOverallIndex !== -1 && desiredDoesntHaveOverall) {
desiredClone.unshift(origClone[origOverallIndex]);
}
// Look for each desired element first - if we find it add its index to the result, increment the pos counter
// and replace its value with the placeholder so we now we've processed it.
let pos = 0;
desiredClone.forEach(function (want) {
const isNegative = want.startsWith("-");
const cleanWant = isNegative ? want.substring(1) : want;
const found = origClone.indexOf(cleanWant);
if (found !== -1) {
result[isNegative ? result.length - 1 : pos++] = found;
origClone[found] = placeholder;
}
});
// Now complete the rest of the result by adding any items that weren't processed in the above loop.
origClone.forEach((c, idx) => {
if (c !== placeholder) result[pos++] = idx;
});
return result;
}
// orderBy takes an array and an array of indexes, and returns the elements from the original array in the specified index order.
static orderBy(orig, indexes = null) {
// Short-circuit if we have nothing to do
if (!orig || !indexes) return orig;
return indexes.map(idx => {
return idx >= 0 && idx < orig.length ? orig[idx] : undefined;
});
}
// calculateCorrelations calculates the importance of a set of independent 'driver' variables in influencing the 'dependent' variable
#calculateCorrelations() {
const corr = [];
if (!this.dependentVar) {
console.error("A DataAdapter must have a dependent variable to calculate importance");
return { corr: [], total: 0 };
}
if (!this.#data || this.#data.length === 0) return corr;
const proj = this.#projects.find(p => p.id === this.#resp.project);
const driverVarNames = Object.keys(this.#data[0]).filter(k => k !== "id" && k !== this.dependentVar);
const driverVars = driverVarNames.map(n => proj.vars[n]);
driverVars.forEach(v => {
let count = 0;
// Calculate the correlation of each driver var
const driverData = this.#data.reduce(
(d, row) => {
let x = row[v.name];
let y = row[this.dependentVar];
// Recoded responses are numbers in the range 1- 5, but raw verbal responses
// qre strings. So if we detected a non-number here we need to recode it.
if (isNaN(x)) x = DataAdapter.#recode(x);
if (isNaN(y)) y = DataAdapter.#recode(y);
if (x && y) {
// x and y are in the closed Likert intervals of [1, 5] or [1, 10]
d.x.push(x);
d.y.push(y);
count++;
}
return d;
},
{ x: [], y: [] }
);
// Bail if we don't have enough data to calculate a correlation
if (driverData.x.length < 2 || driverData.y.length < 2) return { corr: [], total: 0 };
try {
const correlation = Stats.corrp(driverData.x, driverData.y);
corr.push({ name: v.name, label: v.caption || v.label, corr: correlation, count });
} catch (e) {
console.error(e.toString());
return { corr: [], total: 0 };
}
});
return { corr, total: this.#data.reduce((t, row) => (t += row[this.dependentVar] ? 1 : 0), 0) };
}
/**
* Recode a verbal satisfaction/agreement scale into a numeric scale.
* @param {string|number} val - The value to be recoded (either a number or a verbal scale label).
* @returns {number|null} - The recoded numeric value or null if unable to recode.
*/
static #recode(val) {
// Handle null/undefined
if (val === null) return null;
if (val === undefined) return undefined;
// Handle numbers
if (!isNaN(val)) return Number(val);
// Check the Sat scale
let idx = DataAdapter.VERBAL_SAT_SCALE.indexOf(val);
if (idx !== -1) return 5 - idx;
// Checl the Agree scale
idx = DataAdapter.VERBAL_AGREE_SCALE.indexOf(val);
if (idx !== -1) return 5 - idx;
return null;
}
// #getSortedIndexes returns the indexes of the sorted array in the original array
static #getSortedIndexes(originalArray, sortedArray) {
// Create a copy of the original array with index information
const indexedArray = originalArray.map((value, index) => ({ value, index }));
// Sort the indexed array by comparing the values with the sorted array
indexedArray.sort((a, b) => sortedArray.indexOf(a.value) - sortedArray.indexOf(b.value));
// Extract the indexes from the sorted indexed array
return indexedArray.map(item => item.index);
}
/**
* Sorts an array by its indexes.
* @param {Array} valuesArray - The array to be sorted.
* @param {Array} sortedIndexes - The sorted indexes indicating the new order.
* @returns {Array} - The array sorted by the given indexes.
*/
static #sortByIndexes(valuesArray, sortedIndexes) {
// Create a new array to store the sorted values
const sortedValues = new Array(valuesArray.length);
// Place each value from the valuesArray into its new sorted position
sortedIndexes.forEach((sortedIndex, idx) => {
sortedValues[idx] = valuesArray[sortedIndex];
});
return sortedValues;
}
// getHighCount looks for individual counts in a count array whose associated labels
// match either the "L5" or "L4" level labels in server.Globals.responses.
static #getHighCount(_, data) {
const categories = data.category.map(c => (c ? c.toLowerCase() : c));
const highResponses = Object.keys(server.Globals.responses).filter(resp => ["L5", "L4"].includes(server.Globals.responses[resp]));
const highCountIndexes = highResponses.reduce((arr, cat) => {
const idx = categories.indexOf(cat.toLowerCase());
if (idx !== -1) arr.push(idx);
return arr;
}, []);
return highCountIndexes.reduce((count, idx) => (count += data.count[idx]), 0);
}
// getYesCount looks for individual counts in a count array whose associated labels
// match a 'Yes'-style response.
static #getYesCount = (aggr, data, group, allCategories) => this.#getAggrCount(aggr, 'yes', data, group, allCategories);
// getYesPercent looks for individual counts in a count array whose associated labels
// match a 'Yes'-style response.
static #getYesPercent(aggr, data, group, allCategories) {
// Grouped 'dist[x]' aggregators need special handling
if (group && (aggr === 'dist' || aggr === 'distx')) {
const counts = this.#getYesCount(aggr, data, group, allCategories);
const totals = this.#getResponseTotal(aggr, data, group, allCategories);
return counts.map((c, idx) => totals[idx] ? c / totals[idx] : null);
}
return this.#getYesCount(aggr, data, group, allCategories) / this.#getResponseTotal(aggr, data, group, allCategories);
}
// getNoCount looks for individual counts in a count array whose associated labels
// match a 'No'-style response.
static #getNoCount = (aggr, data, group, allCategories) => this.#getAggrCount(aggr, 'no', data, group, allCategories);
// getNoPercent looks for individual counts in a count array whose associated labels
// match a 'No'-style response.
static #getNoPercent(aggr, data, group, allCategories) {
// Grouped 'dist[x]' aggregators need special handling
if (group && (aggr === 'dist' || aggr === 'distx')) {
const counts = this.#getNoCount(aggr, data, group, allCategories);
const totals = this.#getResponseTotal(aggr, data, group, allCategories);
return counts.map((c, idx) => totals[idx] ? c / totals[idx] : null);
}
return this.#getNoCount(aggr, data, group, allCategories) / this.#getResponseTotal(aggr, data, group, allCategories);
}
// getDontKnowCount looks for individual counts in a count array whose associated labels
// match a 'Not applicable / Don't know'-style response.
static #getDontKnowCount = (aggr, data, group, allCategories) => this.#getAggrCount(aggr, "not applicable / don't know", data, group, allCategories);
// getConfidence calculates the confidence level of each result
static #getConfidence = (aggr, data, group, allCategories) => data.category.map((_, idx) => {
if (data.value[idx] === undefined || data.count[idx] === undefined) return undefined;
if (data.value[idx] === null || data.count[idx] === null) return null;
return Math.sqrt(data.value[idx] * (1 - data.value[idx]) / data.count[idx]) * 1.96;
});
// getAggrCount looks for individual counts in a count array whose associated labels
// match the specified response
static #getAggrCount(aggr, response, data, group = null, allCategories = []) {
response = response ? response.toLowerCase() : response;
// If the response we're looking for is null, include the null category
const includeNulls = response === null;
let dataCategories = includeNulls ? data.category : data.category.filter(c => c);
dataCategories = dataCategories.map(c => (c ? c.toLowerCase() : c));
// Grouped 'dist[x]' aggregators need special handling
if (group && (aggr === 'dist' || aggr === 'distx')) {
return allCategories.map(getCat => {
const idx = dataCategories.findIndex(c => {
if (!c) return false;
const parts = c.split('|');
return parts[0] === getCat.toLowerCase() && parts[1] === response;
});
return idx !== -1 ? data.value[idx] : null;
});
}
const idx = dataCategories.findIndex(c => (c ? c.startsWith(response) : false));
return idx !== -1 ? data.value[idx] : null;
}
// getResponseCount returns an array of total responses in each category
static #getResponseCount(aggr, data, group = null, allCategories = []) {
// Grouped 'dist[x]' aggregators need special handling
if (group && (aggr === 'dist' || aggr === 'distx')) {
const categories = data.category.map(c => (c ? c.toLowerCase() : c));
return allCategories.map(getCat => {
return categories.reduce((t, c, idx) => {
if (!c) return;
const parts = c.split('|');
if (parts[0] === getCat.toLowerCase()) t += data.value[idx];
return t;
}, 0);
});
}
return data.value;
}
// getResponseTotal returns an array of total responses in each category
static #getResponseTotal(aggr, data, group = null, allCategories = []) {
// Grouped 'dist[x]' aggregators need special handling
if (group && (aggr === 'dist' || aggr === 'distx')) {
const categories = data.category.map(c => (c ? c.toLowerCase() : c));
return allCategories.map(getCat => {
return categories.reduce((t, c, idx) => {
if (!c) return;
const parts = c.split('|');
if (parts[0] === getCat.toLowerCase()) t += data.value[idx];
return t;
}, 0);
});
}
return data.value.reduce((t, c) => (t += c), 0);
}
// getScaleType returns the scale type of the given labels
static #getScaleType = (labels) => {
for (const lbl of labels) {
const test = lbl.toLowerCase();
if (test.includes('satisfied')) return 'Satisfaction';
if (test.includes('agree')) return 'Agree';
if (test.includes('easy')) return 'Ease';
}
};
// compareRanges compares two ranges of the form [min, max].
// It returns:
// 0 if the given ranges overlap (or share a common endpoint);
// -1 if range2 is entirely below range1;
// 1 if range2 is entirely above range1;
static #compareRanges(range1, range2) {
const [min1, max1] = range1;
const [min2, max2] = range2;
if (max2 < min1) return -1;
if (min2 > max1) return 1;
return 0;
}
// NOTE: The 'category' key must always be specified first here
static #aggrDataFields = {
avg: {
category: "#GROUP#",
value: "#VAR_NAME#",
count: "#VAR_NAME#__count",
sd: "#VAR_NAME#__sd",
sdp: "#VAR_NAME#__sdp",
total: "#VAR_NAME#__total",
},
count: {
category: "#GROUP#",
value: "#VAR_NAME#",
count: "#VAR_NAME#",
total: (_, data) => data.count.reduce((t, c) => (t += c), 0),
},
counta: {
category: "#GROUP#",
value: "count",
count: "count",
total: (_, data) => data.count.reduce((t, c) => (t += c), 0),
},
dist: {
category: ['#GROUP#', '#VAR_NAME#_value'],
value: "#VAR_NAME#_count",
count: (aggr, data, group, categories) => this.#getResponseCount(aggr, data, group, categories),
total: (aggr, data, group, categories) => this.#getResponseTotal(aggr, data, group, categories),
high: (aggr, data) => this.#getHighCount(aggr, data),
yes: (aggr, data, group, categories) => this.#getYesCount(aggr, data, group, categories),
yes_percent: (aggr, data, group, categories) => this.#getYesPercent(aggr, data, group, categories),
no: (aggr, data, group, categories) => this.#getNoCount(aggr, data, group, categories),
no_percent: (aggr, data, group, categories) => this.#getNoPercent(aggr, data, group, categories),
dontknow: (aggr, data, group, categories) => this.#getDontKnowCount(aggr, data, group, categories),
},
distx: {
category: ['#GROUP#', '#VAR_NAME#_value'],
value: "#VAR_NAME#_count",
count: (aggr, data, group, categories) => this.#getResponseCount(aggr, data, group, categories),
total: (aggr, data, group, categories) => this.#getResponseTotal(aggr, data, group, categories),
high: (aggr, data) => this.#getHighCount(aggr, data),
yes: (aggr, data, group, categories) => this.#getYesCount(aggr, data, group, categories),
yes_percent: (aggr, data, group, categories) => this.#getYesPercent(aggr, data, group, categories),
no: (aggr, data, group, categories) => this.#getNoCount(aggr, data, group, categories),
no_percent: (aggr, data, group, categories) => this.#getNoPercent(aggr, data, group, categories),
dontknow: (aggr, data, group, categories) => this.#getDontKnowCount(aggr, data, group, categories),
didntanswer: (aggr, data, group, categories) => data.didntanswer,
},
distinct: {
category: "#VAR_NAME#",
value: "#VAR_NAME#",
},
split: {
category: "_bucket",
value: "count",
count: "count",
},
vsatsplit: {
category: "#GROUP#",
high: "#VAR_NAME#__high",
neutral: "#VAR_NAME#__neutral",
low: "#VAR_NAME#__low",
count: "#VAR_NAME#__count",
total: "#VAR_NAME#__total",
},
vsattopbox: {
category: "#GROUP#",
value: "#VAR_NAME#__topbox",
raw: "#VAR_NAME#__value",
count: "#VAR_NAME#__count",
total: "#VAR_NAME#__count",
confidence: (aggr, data, group, categories) => this.#getConfidence(aggr, data, group, categories),
},
};
}
DataAdapter.VERBAL_SAT_SCALE = [
"Very satisfied",
"Fairly satisfied",
"Neither satisfied nor dissatisfied",
"Fairly dissatisfied",
"Very dissatisfied",
];
DataAdapter.VERBAL_AGREE_SCALE = [
"Strongly agree",
"Agree",
"Neither agree nor disagree",
"Disagree",
"Strongly disagree",
];
DataAdapter.VERBAL_INAPPLICABLE = [
"Not applicable / Don't know",
"Refused or unable to answer",
"Not answered",
"Don't know",
"Not applicable",
"nothing / don't know / n/a",
];
DataAdapter.YES_NO_ORDER = ["Yes", "No"];
DataAdapter.NO_YES_ORDER = ["No", "Yes"];
DataAdapter.NOT_KNOWN_TO_END_ORDER = ["-Not Known"]; // A leading '-' means move this category to the end