QScript Functions to Generate Outputs

From Q
Jump to navigation Jump to search

The functions below are designed to assist the QScript communicate to the user of the regarding changes that have been made by the QScript. The main ways of reporting on changes are to use logs, which will be shown in the box that appears at the completion of the QScript, and to add groups of tables for the questions that have been changed.

To make these functions available when writing a QScript or Rule see JavaScript Reference.

generateGroupOfSummaryTables(group_name, question_array)

This function generates a new group in the report tree with the name specified by group_name that contains a summary table for each question in the question_array. It returns the new group object.

generateSubgroupOfSummaryTables(group_name, within_group, question_array)

This function generates a new group in the input group within_group with the name specified by group_name that contains a summary table for each question in the question_array. It returns the new group object. generateSubgroupOfSummaryTables

generateGroupOfSummaryPlots(group_name, question_array)

This function generates a new group in the report tree with the name specified by group_name that contains a summary plot for each question in the question_array. It returns the new group object.

logQuestionList(message, question_array)

This function creates a new log entry that begins with the input message and then a list of the names of the questions in question_array, where each is given a new line. Logs are displayed once the script has finished running.

simpleHTMLReport(paragraphs, title, group, set_focus, web_mode)

Generates a Text item in the report group group with the elements of paragraphs as content, and the specified title. paragraphs should be an array of strings. Each string will be added as a separate line. If you want this new report to be selected in the Report tree at the conclusion of the script supply set_focus as true.

conditionallyEmptyLog(message)

If the Q version is such that we can avoid displaying blank logs then do nothing, otherwise log the message.

makeWordList(words)

Takes an array of variable names (strings) as input and returns a formatted string of the names in a comma separated list.

insertGroupAtHoverButtonIfUsed

Given a QScript ReportGroup, i.e. a page or folder in the Displayr Report Tree, this function will move the provided group to the current position of the Page Hover Button in Displayr, or do nothing if the calling script was not executed via that button.

layoutOutputsOnPage

Given an array of outputs and a page/ReportGroup in Displayr, this function adds the outputs to the page in a grid ensuring there are no overlaps between any of the outputs and the page title, if one is present.

distributeOutputsHorizontally

This is a helper function for layoutOutputsOnPage, it converts arrays of output positions from being left-justified to horizontally-centred, returning and array of modified left positions that can be assigned to the outputs to horizontally centre them on the page (i.e. so that the left and right margins of the page are equal).

createReport

Adds a new report QScript ReportGroup containing crosstabs for a given set of primary and secondary variable sets. The first argument outputs should be an array of objects which are pairs {primary, secondary}, where primary is always a QScript Question object, and secondary can be a question object, or "SUMMARY" or "RAW DATA". The second argument, names should be an array of the names of pages which go with each of the outputs. The argument statistics should be an array containing strings that correspond to the names of the statistics which should be added to the pages. There are additional arguments weight, filters, and as_plot for supplying an optional weight variable to apply to all tables, an optional array of filter variables to apply to all tables, and a true/false value controlling whether to create small multiples bar charts instead of tables for each output, respectively.

addCrosstab

This is a helper function for createReport that adds a page to a provided QScript ReportGroup containing a crosstab (QScript Table) for a given set of primary and secondary variable sets. The first argument parent is the QScript ReportGroup to append the new page to. The second argument, variable_sets, is a single element from the outputs argument documented above for createReport. The third argument should be the string to use for the page name. The remaining arguments are the same as for createReport. The function returns parent with the new page added.

createGroupAtHoverOrBelowCurrentGroup

Creates a new report group at the position of the page hover button, if it was used to execute the script, or below the current page in the Report Tree. The single argument page_name should be a string specifying the name to assign the created group. The function returns the created QScript ReportGroup.

Source Code

// also includes JavaScript Array Functions needed
// for splitArrayIntoApplicableAndNotApplicable
includeWeb('QScript Value Attributes Functions');  // for nonMissingValueLabels
includeWeb("QScript Table Functions");
includeWeb("QScript Utility Functions");  // for inDisplayr

// Standard styles for generating outputs
_GLOBAL_REPORT_BODY_TEXT_STYLE = { font: 'Tahoma', size: 10 };
_GLOBAL_REPORT_HEADING_STYLE = { font: 'Tahoma', size: 14 };
_GLOBAL_ITEM_HEADING_STYLE = { font: 'Tahoma', size: 20 };

// TELLING THE USER WHAT HAS BEEN DONE

function generateGroupOfSummaryTables(group_name, question_array) {
    return createReport(group_name, question_array, question_array.map(q=>q.name),
                 null, null, [], false, Infinity, false, false);
}

function generateSubgroupOfSummaryTables(group_name, within_group, question_array) {
    let new_group = createReport(group_name, question_array,
                                 question_array.map(q=>q.name),
                                 null, null, [], false, Infinity, false, false);
    let sub_items = within_group.subItems;
    within_group.moveAfter(new_group, sub_items[sub_items.length - 1]);
    return new_group;
}

function generateGroupOfSummaryPlots(group_name, question_array) {
    return createReport(group_name, question_array,
                        question_array.map(q=>q.name),
                        null, null, [], false, Infinity, false, true);
}
  
function logQuestionList(message, question_array) {
    log(message);
    log(question_array.map(function(q) { return q.name; } ).join("\r\n"));
}

// If the Q version is such that we can avoid displaying blank logs
// then do nothing, otherwise log the message. This allows us to phase
// out log messages which are not informative.
function conditionallyEmptyLog(message) {
	if (fileFormatVersion() < 8.86)
		log(message);
}

// Adds a text item to the top of group with the specified
// title and paragraphs. paragraphs should be an array of strings
// each of which will be added on a separate line.
// Make set_focus true if you want to change the selected items
// so that the user's view focuses on the new report page (only
// works in 4.11 and greater)
function simpleHTMLReport(paragraphs, title, group, set_focus, web_mode) {
    
    var title_builder = Q.htmlBuilder();
    title_builder.setStyle(_GLOBAL_ITEM_HEADING_STYLE);
    title_builder.appendParagraph(title);
    if (!web_mode)
    {
        var report = group.appendText();
        group.moveAfter(report, null);
        report.title = title_builder;
    }
    else
    {
        var title_text = group.appendText();
        title_text.top = 50;
        title_text.content = title_builder;
        title_text.name = "Title";

        var report = group.appendText();
        report.top = 120;
        report.name = "Content"
    }

    var content = Q.htmlBuilder();
    content.setStyle(_GLOBAL_REPORT_BODY_TEXT_STYLE);
    paragraphs.forEach(function (paragraph) {
        content.appendParagraph(paragraph);
    });
    report.content = content; 
    // More recent Q versions can point the user to the report
    if (fileFormatVersion() > 8.65 && set_focus) 
        project.report.setSelectedRaw([report]);
    return report;
}

// Takes an array of variable names (strings) as input
// and returns a formatted string of the names in a 
// comma separated list.
// E.g New variables 'var1', 'var2', and 'var3' created.
function makeWordList(words) {
    if (Array.isArray(words) && words.length == 1){
        return "New variable '" + words[0] + "' created. ";
    }else if (Array.isArray(words) && words.length > 1) {
        return "New variables '" + words.slice(0,words.length-1).join("', ") 
                 + "' and '" + words[words.length-1] + "' created. ";
    }else
         return "";
}

// In Displayr, this function will move the provided group (page or folder in a Displayr
// document) to the current position of the Page Hover Button, or do nothing if the
// calling script was not executed via that button.
// @param group A QScript Group to be moved
// to the location of the page hover button, if it was used to call the QScript
// @return NULL, called for its side effects
function insertGroupAtHoverButtonIfUsed(group) {
    if (typeof project.getReportInsertingAtItem === "undefined")
        return;
    let group_at_insert_point = project.getReportInsertingAtItem();
    if (group_at_insert_point === null)
        return;
    let parent_group = group_at_insert_point.parentGroup();
    let insert_below = !project.isInsertingAbove();
    if (insert_below) {
        parent_group.moveAfter(group, group_at_insert_point);
    } else {
        // find group before the insert-at group
        let sub_items = parent_group.subItems;
        let group_at_insert_guid = group_at_insert_point.guid;
        let idx = sub_items.findIndex(item => item.guid === group_at_insert_guid);
        if (idx > 0)
            parent_group.moveAfter(group, sub_items[idx - 1]);
        else if (idx === 0)
            parent_group.moveAfter(group,null);
    }
    return;
}

// Layout an array of outputs of constant height/width on a Displayr page
// @param outputs An array of outputs such as Tables, Charts, and R Outputs
// @param page The Displayr page (QScript ReportGroup) to place the outputs on
// @param height Single number height for each output
// @param width Single number width for each output
// @param padding_left Width in pixels of spacing to leave between outputs in the same row
// @param padding_bottom Height in pixels of spacing between rows of outputs
function layoutOutputsOnPage(outputs,page,height,width,padding_left,padding_bottom) {
    const page_height = page.height;
    const page_width = page.width;
    const has_title = page.subItems.length > 0 && page.subItems[0].type === "Text";
    const title_height = has_title ? page.subItems[0].height + page.subItems[0].top : 0;

    let repArray = (a,times) => Array(times).fill(a).flat();
    let repArrayEach = (a,each) => a.flatMap(i => repArray(i,each));
    let seqIntArray = (n) => Array(n).fill(0).map((a,i)=>i);
    
    let n_outputs = outputs.length;
    let n_outputs_per_row = Math.floor((page_width+padding_left)/(width+padding_left));
    n_outputs_per_row = Math.max(1,n_outputs_per_row);
    let n_rows = Math.ceil(n_outputs/n_outputs_per_row);
    let row_nums = repArrayEach(seqIntArray(n_rows),n_outputs_per_row);
    let tops = row_nums.map(row => title_height + (height+padding_bottom)*row);
    let lefts = repArray(seqIntArray(n_outputs_per_row).map(i=>i*(width+padding_left)),n_rows);
    lefts = distributeOutputsHorizontally(page,row_nums.slice(0,n_outputs),lefts.slice(0,n_outputs),repArray(width,n_outputs));
    outputs.map((obj,i) => {
	obj.height = height;
	obj.width = width;
	obj.left = lefts[i];
	obj.top = tops[i];
	return obj;
    });
    return;
}

// Displayr-only helper for layoutOutputsOnPage
// Converts arrays of output positions from being left-justified to vertically-centred
// @return A copy of `lefts` modified so outputs are vertically-centred
function distributeOutputsHorizontally(page, rows, lefts, widths) {
    const page_width = page.width;
    let new_lefts = [];
    let row_lefts, last_width_in_row, n_outputs_in_row, idx;
    idx = 0;
    for (let i = 0; i <= rows[rows.length - 1]; i++) {
        row_lefts = lefts.filter((l,idx) => rows[idx] == i);
        n_outputs_in_row = row_lefts.length
        last_width_in_row = widths[rows.lastIndexOf(i)];
        remaining_width = page_width - last_width_in_row - row_lefts[n_outputs_in_row - 1];  
        for (let j = 1; j <= row_lefts.length; j++) {
            new_lefts.push(lefts[idx]+j*remaining_width/(n_outputs_in_row+1));
            idx++;
        }         
    }
    return new_lefts;
}

/// <summary>Adds a new report (QScript ReportGroup) containing crosstabs for a given set of primary and secondary variable sets.</summary>
/// <param name="report_name">The name to use for the report/folder in the Report Tree.</param>
/// <param name="outputs">An array of objects which are pairs {primary question, secondary question} where primary question is always a question object, and secondary question can be a question object, or "SUMMARY" or "RAW DATA". Alternatively, can be an array of QScript Questions to create a report of SUMMARY tables</param>
/// <param name="page_titles">An array of the titles of pages which go with each of the outputs. An array of null values can be used to insert blank pages without titles with names drived from the questions in outputs.</param>
/// <param name="statistics">An array containing strings that correspond to the names of the statistics which should be added to the pages. Can be empty if nothing is to be added.</param>
/// <param name="weight">Weight variable to be applied to all outputs. Can be null if no weight.</param>
/// <param name="filters">Array of filter variables to be applied to the outputs. Can be empty if no filters needed.</param>
/// <param name="as_plot">Boolean indicating if plots are to be created</param>
/// <return>A QScript ReportGroup containing the report of crosstabs</return>
function createReport(report_name, outputs, page_titles, statistics, weight, filters,
                      delete_insignificant, p_value_cutoff, sort, as_plot) {
    includeWeb("QScript Functions for Sort and Delete");
    
    const web_mode = inDisplayr();
    const report = createGroupAtHoverOrBelowCurrentGroup(report_name);
    
    if (outputs.every(obj => obj.hasOwnProperty("type") && obj.type === "Question"))
        outputs = outputs.map(q => ({primary: q,
	    secondary: /^Text/.test(q.questionType) ? "RAW DATA" : "SUMMARY"}));

    outputs = outputs.filter(obj => obj.primary.isValid && (typeof obj.secondary === 'string' || !!obj.secondary.isValid ))

    const n_outputs = outputs.length;

    for (let i = 0; i < n_outputs; i++) 
	   addCrosstab(report, outputs[i], page_titles[i], statistics, weight, filters, false);
    
    let report_items = report.subItems.filter(item => item.type !== "Text");
    let tables = (web_mode ? report_items.flatMap(
                        page => page.subItems.filter(si => si.type === "Table")
                        ) : report_items);
    let orig_guids = tables.map(table => table.guid);
    
    let min_p_values;
    if (delete_insignificant || sort)
        min_p_values = tables.map(minimumPValue);
    if (delete_insignificant) {
        project.report.setSelectedRaw(tables);
        deleteInsignificantTablesPlots(p_value_cutoff, false, min_p_values);
        // update min_p_values to drop deleted tables
        report_items = report.subItems.filter(item => item.type !== "Text");
        tables = (web_mode ? report_items.flatMap(
            page => page.subItems.filter(si => si.type === "Table")
        ) : report_items);
        let remaining_guids = tables.map(table => table.guid);
        min_p_values = min_p_values.filter((p, idx) => remaining_guids.includes(orig_guids[idx]));
    }
    if (sort) {
        if (web_mode) {
            sortPagesBySignificance(report_items, false, min_p_values);
        } else {
            sortItemsBySignificance(tables, report, min_p_values);
        }
    }
    
    if (as_plot) {
        tables.forEach(convertTableToPlot);
        report_items = report.subItems.filter(item => item.type !== "Text");
    }

    if (report_items.length > 0) { 
        project.report.setSelectedRaw([!web_mode ? report_items[0] : 
				       report_items[0].subItems.find(item =>
					   ["Plot", "Table"].includes(item.type))
				      ]);
    } else if (delete_insignificant) {
        log("No tables met the chosen level of significance for inclusion in the report.");
        report.deleteItem();
        return false;
    }
    return report;
}

/// <summary>Adds a page to a provided QScript ReportGroup containing
/// a crosstab (QScript Table) for a given set of primary and secondary variable
/// sets.</summary>
///<param name="variable_sets">An object containing
/// the pairs {primary question, secondary question} where primary
/// question is always a question object, and secondary question can
/// be a question object, or "SUMMARY" or "RAW DATA".</param>
/// <param name="title">The title to use for the created page. Can be null to create a blank page</param>
/// <param name="statistics">An array containing strings that correspond to
/// the names of the statistics which should be added to the pages. Can be empty
/// if nothing is to be added.</param>
/// <param name="weight">Weight variable to be applied
/// to the crosstab. Can be null if no weight.</param>
/// <param name="filters">Array of filter variables to be applied to the
/// crosstab. Can be empty if no filters needed.</param> <param
/// name="as_plot">Boolean indicating if a bar chart is to be
/// created instead of a table.</param>
/// <return><c>parent</c> with an additional page appended containing the
/// crosstab.</return>
function addCrosstab(parent,variable_sets,title,statistics,weight,filters,as_plot) {
    let web_mode = inDisplayr();
    let page_type = title ? "Item" : "Blank";
    let page = web_mode ? parent.appendPage(page_type) : parent;
    let table = page.appendTable();
    table.primary = variable_sets.primary;
    table.secondary = variable_sets.secondary;
    if (statistics != null)
        table.cellStatistics = statistics;
    if (web_mode) {
	if (title == null) {
	    title = table.primary.name;
	    if (typeof table.secondary !== "string")
		title += " by " + table.secondary.name;
	}
	page.name = title;
	if (page_type !== "Blank") {
	    let title_text = page.subItems.filter(item => item.type === "Text" &&
						  item.name === "Title placeholder")[0];
	    // Check existence of title text as this depends on Page Master layout
	    if (typeof(title_text) !== 'undefined' && title_text.hasOwnProperty('text'))
		title_text.text = title;
	}
    }
    if (filters.length > 0)
	   table.filters = filters;
    if (weight !== null)
	   table.weight = weight;
    if (as_plot) {
	   convertTableToPlot(table);
    }
    return parent;
}

function convertTableToPlot(table) {
    let secondary = table.secondary;
    const web_mode = inDisplayr();
    if (secondary === "SUMMARY") {
        convertSummaryTableToPlot(table);
    } else if (secondary === "RAW DATA") {
	   convertRawTableToPlot(table);
    } else {
        if (web_mode)
            table.switchTo("Visualization - Bar - Bar Crosstab");
        else {
            let parent = table.group;
            let primary = table.primary;
            table.deleteItem();
            parent.appendPlot("Grid of bars plot", [primary, secondary]);      
        }
    }
    return;
}

function convertRawTableToPlot(table) {
    if (table.type !== "Table" || table.secondary !== "RAW DATA")
	   throw new UserError("This function should only be used with a table containing RAW DATA");

    const question = table.primary;
    const qtype = question.questionType;
    const web_mode = inDisplayr();
    let chart_type = '';
    if (qtype === "Numeric") {
	   chart_type = web_mode ? 'Visualization - Distributions - Histogram' : 'Histogram';
    } else if (/^Text/.test(qtype)) {
	   chart_type = 'Visualization - Word Cloud - Word Cloud';
    } else {
	   chart_type = web_mode ? 'Visualization - Distributions - Histogram' : 'Histogram';
    }
    if (web_mode) {
        try {
            table.switchTo(chart_type);    
        } catch (e) {
            log("Could not create chart for " + qname + ": " + e);
        }
    } else {
        let parent = table.group;
        let primary = table.primary;
        table.deleteItem();
        parent.appendPlot(chart_type, [primary]);    
    }
    return;
}


function convertSummaryTableToPlot(table) {
    if (table.type !== "Table" || table.secondary !== "SUMMARY")
	   throw new UserError("This function should only be used with a SUMMARY table.");
    
    const question = table.primary;
    const qtype = question.questionType;
    const qname = question.name;
    const web_mode = inDisplayr();
    let chart_type;
    if (qtype === "Pick One") {
        const non_missing_value_labels = nonMissingValueLabels(question);
        const num_categories = non_missing_value_labels.length;
        chart_type = (num_categories > 7 ?
                            (web_mode ? 'Visualization - Column - Column with Tests' : 'Column plot') :
                            (web_mode ? 'Visualization - Pies - Pie with Tests' : 'Pie plot') );
    } else if (["Pick Any", "Pick Any - Compact", "Number - Multi", "Ranking"].includes(qtype)) {
        chart_type = web_mode ? "Visualization - Column - Column with Tests" : 'Column plot';
    } else if (qtype === "Experiment") {
        chart_type = web_mode ? 'Visualization - Bar - Bar with Tests' : 'Bar plot';
    } else if (qtype === "Number") {
        chart_type = web_mode ? 'Visualization - Distributions - Categorizable Histograms' : 'Histogram';
    } else if (qtype === "Pick One - Multi") {
        chart_type = web_mode ? 'Visualization - Column - Stacked Column with Tests' : 'Stacked column plot';
    } else if (["Pick Any - Grid", "Number - Grid"].includes(qtype)) {
        chart_type = web_mode ? 'Visualization - Bar - Bar Crosstab' : 'Grid of bars plot';
    } else {
        chart_type = web_mode ? 'Visualization - Column - Column with Tests' : 'Column plot';
    }
    if (web_mode) {
        try {
            table.switchTo(chart_type);
        } catch(e) {
            log("Could not create chart for " + qname + ": " + e);
        }    
    } else {
        let parent = table.group;
        let primary = table.primary;
        table.deleteItem();
        parent.appendPlot(chart_type, [primary]);
    }
    return;
}

/// <summary>Creates a new report group at the position of the page hover button, if it was used to execute the script, or under the current page.</summary>
/// <param name="page_name">Name to assign the created group.</param>
/// <return>The created QScript ReportGroup.</return>
function createGroupAtHoverOrBelowCurrentGroup(page_name) {
    let web_mode = inDisplayr();
    let curr_page = (!web_mode || project.currentPage === undefined ?
                     false : project.currentPage());
    let parent_group = !curr_page ? project.report : curr_page.parentGroup();
    let new_page = !curr_page && !web_mode ? parent_group.appendGroup() : parent_group.appendPage("Title");

    // some templates reverse order of title and subtitle in page tree
    // update title and delete subtitle
    new_page.name = page_name;
    let subitems = splitArrayIntoApplicableAndNotApplicable(new_page.subItems,
                        item => item.type === "Text" &&
			item.name === "Title placeholder");
    if (subitems.applicable.length > 0)
	subitems.applicable[0].text = page_name;
    if (subitems.notApplicable.length > 0)
	subitems.notApplicable[0].deleteItem();

    if (curr_page)
        parent_group.moveAfter(new_page,curr_page);
    insertGroupAtHoverButtonIfUsed(new_page);
    return new_page;
}

function reportNewRQuestion(questions, top_group_name) {
    includeWeb("QScript Functions to Generate Outputs");
    var is_displayr = inDisplayr();

    // Currently nothing is reported inside Displayr
    if (!is_displayr){
        var is_single_q = typeof(questions.dataFile) !== 'undefined';
        if (is_single_q)
        {
            var data_file = questions.dataFile;
            var new_name = prompt("Enter a name for the new question : ", questions.name);
            if (new_name !== questions.name)
                questions.name = new_name;
            var new_group = generateGroupOfSummaryTables(top_group_name, [questions]);
        }
        else
        {
            var data_file = questions[0].dataFile;
            var new_group = generateGroupOfSummaryTables(top_group_name, questions);
        }

        // This currently does not allow the variable to be selected
        // which is why the summary tables are created instead
        // (can be updated after RS-6669 is completed)
        if (fileFormatVersion() > 8.65)
            project.report.setSelectedRaw([new_group.subItems[0]]);
        else
        {
            if (is_single_q)
                log("The transformed question '" + questions.name + "' has been added to the dataset " + data_file.name);
            else
                log("Tables showing the new questions have been added into the folder " + top_group_name);
        }
    }
}

See also