dataadapter.js

/**
 *
 * @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