Skip to content
Snippets Groups Projects
hawat-charts.js 33.03 KiB
/*******************************************************************************

    FUNCTIONS FOR CHARTS AND DATASET TABLES.

*******************************************************************************/

// The visualisation registry was implemented due to the necessity to update
// specific visualisations on tab change. It is currently still proof of concept.
var VISUALISATION_REGISTRY = {};
var KW_REST = '__REST__';
var KW_DATE = '_date';
var TOPLIST_THRESHOLD = 20;

function register_visualisation(visualisation_id, visualisation) {
    VISUALISATION_REGISTRY[visualisation_id] = visualisation;
    console.log("Registered visualisation: " + visualisation_id);
}
function update_visualisation(visualisation_id) {
    if (!(visualisation_id in VISUALISATION_REGISTRY)) {
        console.log("Skipping updating: " + visualisation_id)
        return;
    }

    try {
        VISUALISATION_REGISTRY[visualisation_id].update();
        console.log("Updated visualisation: " + visualisation_id);
    }
    catch(err) {
        console.error("Unable to update visualisation " + visualisation_id + ":" + err.message);
    }
}
function chart_statusbar_set(message) {
    $('#statusbar-chart-rendering').text(message);
    $('#statusbar-chart-rendering').show();
}
function chart_statusbar_clear() {
    $('#statusbar-chart-rendering').hide();
    $('#statusbar-chart-rendering').text('');
}

// Calculate delta of minimal and maximal value of time scale of given chart.
function calculate_chart_timescale_delta(chartobj) {
    scaledomain = chartobj.xAxis.scale().domain();
    datemin = new Date(scaledomain[0]);
    datemax = new Date(scaledomain[scaledomain.length-1]);
    return (datemax - datemin) / 1000;
}

// Convert given list of objects (LoOs) into list of lists (LoLs).
function loos_to_lols(header_row, data_rows, key_attr = 'key', val_attr = 'value') {
    return header_row.concat(
        data_rows.map(function(data) {
            return [
                data[key_attr],
                data[val_attr]
            ];
        })
    );
}

// Convert given list of objects (LoOs) into list of lists (LoLs) with given header keywords.
function loos_to_lols_kw(header_row, data_rows) {
    return [header_row.map(function(column) {
        return column.key;
    })].concat(
        data_rows.map(function(data) {
            result = [];
            header_row.forEach(function(column) {
                result.push(data[column.ident]);
            });
            return result;
        })
    );
}

// Convert given list of lists (LoLs) into CSV string.
function lols_to_csv(data, args = {}) {
	var result, ctr, columnDelimiter, lineDelimiter;

    if (data == null || !data.length) {
        return null;
    }

    columnDelimiter = args.columnDelimiter || ';';
    lineDelimiter = args.lineDelimiter || '\n';

    result = '';

    data.forEach(function(row) {
        ctr = 0;
        row.forEach(function(item) {
            if (ctr > 0) result += columnDelimiter;

            result += item;
            ctr++;
        });
        result += lineDelimiter;
    });

    return result;
}

// Alternative method for downloading JavaScript variable as CSV, resource:
// 		https://halistechnology.com/2015/05/28/use-javascript-to-export-your-data-as-csv/
// Currently rejected in favor of using this recipe:
// 		https://codepen.io/vidhill/pen/bNPEmX?editors=1010
// I am keeping it here for future consideration.
function download_as_CSV(args) {
    var data, filename, link;
    var csv = lols_to_csv(args.data);
    if (csv == null) return;

    filename = args.filename || 'export.csv';
    console.log('Downloading CSV: ' + filename);

    if (!csv.match(/^data:text\/csv/i)) {
        csv = 'data:text/csv;charset=utf-8,' + csv;
    }
    data = encodeURI(csv);

    link = document.createElement('a');
    link.setAttribute('href', data);
    link.setAttribute('download', filename);
    link.click();
}

// Render multi-timeline chart.
function render_chart_timeline_multi(chid, chart_data, params) {
	console.log('Rendering chart: ' + chid);

	nv.addGraph(function() {
        var chart = nv.models.multiBarChart()
            .reduceXTicks(true)   // If 'false', every single x-axis tick label will be rendered.
            .rotateLabels(0)      // Angle to rotate x-axis labels.
            .margin({bottom: 40}) // Make bottom margin big enough to fit the datetime labels.
            .showControls(false)  // Do not allow user to switch between 'Grouped' and 'Stacked' mode.
            .stacked(true)        // Use 'Stacked' mode by default.
            .showLegend(true)     // Show legend for datasets.
            .groupSpacing(0.1)    // Distance between each group of bars.
          ;

        // Custom tick formatter for datetime labels of X axis.
        // Must be defined here, because we are using closure
        // to ge to the chart object itself.
        var customTickFormat = d3.time.format.multi([
            ["%Y-%m-%d", function(d) { return calculate_chart_timescale_delta(chart) > (60*60*24); }],
            ["%H:%M:%S", function(d) { return d.getSeconds()!= 0; }],
            ["%H:%M",    function(d) { return true }]
        ]);

        // Customize the Y axis.
        chart
            .yAxis.tickFormat(d3.format(',d')) // Event counts are just integers.
            .axisLabel(params.ylabel || 'Count [#]')
        ;

        // Customize the X axis.
        chart
            .xAxis.tickFormat(function(d) {
                return customTickFormat(new Date(d))
            })
            .axisLabel(params.xlabel || 'Date')
        ;

        // Customize the tooltip.
        chart.tooltip.headerFormatter(function(d) {
            return d3.time.format.iso(new Date(d)); // Keep the date in tooltip header in full ISO format.
        });

        // Select the appropriate SVG element and bind datum to the chart.
        d3.select("#" + chid + " svg")
            .datum(chart_data)
            .transition().duration(350)
            .call(chart);

        nv.utils.windowResize(chart.update);

        register_visualisation(chid, chart);

        return chart;
    });
}

// Convert timeline chart raw data to representation appropriate for D3 bar chart.
function dstl_to_chart(dataset, data_series) {
    return data_series.map(function(serie) {
        return {
            ident: serie.ident,
            key: 'key' in serie ? serie.key : serie.ident,
            values: dataset.map(function(tl_data) {
                return {
                    x: tl_data._date,
                    y: tl_data[serie.ident] || 0
                };
            })
        };
    });
}

// Convert timeline chart raw data to representation appropriate for D3 scatter chart.
function dsts_to_chart(dataset, data_series) {
    return data_series.map(function(serie) {
        value_list = [];
        dataset.forEach(function(data_days, idx_day) {
            data_days.forEach(function(data_hours, idx_hour) {
                if (data_hours[serie.ident]) {
                    value_list.push({
                        x:    idx_hour,
                        y:    idx_day,
                        size: data_hours[serie.ident]
                    });
                }
            });
        });
        return {
            ident: serie.ident,
            key: 'key' in serie ? serie.key : serie.ident,
            values: value_list
        };
    });
}

// Convert timeline chart raw data to representation appropriate for D3 pie chart.
function dstl_to_pie(dataset, data_series) {
    return data_series.map(function(serie) {
        return {
            ident: serie.ident,
            key: 'key' in serie ? serie.key : serie.ident,
            value: d3.nest().rollup(function(rv) {
                return d3.sum(rv, function(g) { return g[serie.ident] });
            }).entries(dataset)
        };
    }).sort(function(a, b) {
        return b.value < a.value ? -1 : b.value > a.value ? 1 : b.value >= a.value ? 0 : NaN;
    });
}

// Prepare dataset for timeline visualisations from given data series list.
function get_dataset_timeline_list(raw_data, data_serie_list) {
    if (raw_data.timeline instanceof Array) {  // Array of objects
        return raw_data.timeline.map(function(data) {
            result = {_date: data[0]};
            data_serie_list.forEach(function(data_serie) {
                result[data_serie.ident] = data_serie.ident in data[1] ? data[1][data_serie.ident] || 0 : 0;
            });
            return result;
        });
    }

    // Object with timelines as values
    return raw_data.timeline[data_serie_list[0].ident].flatMap(function(data, i) {
        if (data[0] == null) {
            return [];
        }

        let result = {_date: data[0]};
        data_serie_list.forEach(function(data_serie){
            result[data_serie.ident] = data_serie.ident in raw_data.timeline ? raw_data.timeline[data_serie.ident][i][1] || 0 : 0;
        });
        return [result];
    });
}

// Prepare dataset for timeline visualisations from given raw_data dictionary.
function get_dataset_timeline_dict(raw_data, data_serie_list, dict_key) {
    if (raw_data.timeline instanceof Array) {  // Array of objects
        return raw_data.timeline.map(function(data) {
            result = {_date: data[0]};
            data_serie_list.forEach(function(data_serie) {
                if (dict_key in data[1] && data_serie.ident in data[1][dict_key]) {
                    result[data_serie.ident] = data[1][dict_key][data_serie.ident];
                }
                else {
                    result[data_serie.ident] = 0;
                }
            });
            return result;
        });
    }
    // Object with timelines as values
    let timeline = raw_data.timeline[dict_key];
    let out = [];
    for (let i = 0; i < timeline.length && timeline[i][0] != null; i++) {
        if (out.length > 0 && out[out.length - 1]._date == timeline[i][0]) {
            out[out.length - 1][timeline[i][1]] = timeline[i][2];
        } else {
            result = {_date: timeline[i][0]};
            result[timeline[i][1]] = timeline[i][2];
            out.push(result);
        }
    }
    return out;
}

// Prepare dataset from given data series list.
function get_dataset_list(raw_data, data_serie_list, key_attr = 'key', val_attr = 'value') {
    return data_serie_list.map(function(data_serie) {
        result = {}
        result['ident'] = data_serie.ident;
        result[key_attr] = data_serie.key;
        result[val_attr] = raw_data[data_serie.ident];
        return result;
    });
}

// Prepare dataset for visualisations from given raw_data dictionary.
function get_dataset_dict(raw_data, key_attr = 'key', val_attr = 'value') {
    return d3.keys(raw_data).map(function(item) {
        result = {};
        result[key_attr] = item;
        result[val_attr] = raw_data[item];
        return result;
    }).sort(function(a,b) {
        return b[val_attr] < a[val_attr] ? -1 : b[val_attr] > a[val_attr] ? 1 : b[val_attr] >= a[val_attr] ? 0 : NaN;
    });
}

// Prepare dataset for visualisations from given raw_data dictionary.
function get_dataset_mdict(raw_data, key_attr = 'label', val_attr = 'value') {
    result = {'key': 'Data'};
    result['values'] = d3.keys(raw_data).map(function(item) {
        res = {};
        res[key_attr] = item;
        res[val_attr] = raw_data[item];
        return res;
    }).sort(function(a,b) {
        return b[val_attr] < a[val_attr] ? -1 : b[val_attr] > a[val_attr] ? 1 : b[val_attr] >= a[val_attr] ? 0 : NaN;
    });
    return [result];
}

// Prepare data series for visualisations from given raw_data dictionary.
function get_series_dict(raw_data, dict_key) {
    return d3.keys(raw_data[dict_key]).map(function(item) {
        result = {};
        result['ident'] = item;
        result['key']   = item;
        result['value'] = raw_data[dict_key][item];
        return result;
    }).sort(function(a,b) {
        return b.value < a.value ? -1 : b.value > a.value ? 1 : b.value >= a.value ? 0 : NaN;
    });
}

// Filter out the '__REST__' keyword from given dataset [{},{},{}].
function _filter_rest(full_data, key_attr = 'key') {
    result = [];
    rest   = 0;
    full_data.map(function(item) {
        if (item[key_attr] == KW_REST) {
            rest = item['value'];
        }
        else {
            result.push(item);
        }
    });
    return [result, rest];
}

// Make mask for creating toplist.
function _make_mask(mask_data) {
    mask = d3.map();
    mask_data.forEach(function(datum) {
        mask.set(datum['ident'], 1);
    });
    return mask;
}

// Mask given dataset [{},{},{}]. Works for get_dataset_timeline_dict().
function mask_toplist(data, mask_data) {
    mask = _make_mask(mask_data);
    result = []
    data.forEach(function(date_datum) {
        date_result = {};
        date_result[KW_REST] = date_datum[KW_REST] || 0;
        date_result[KW_DATE] = date_datum[KW_DATE];
        for (key in date_datum) {
            if ([KW_REST, KW_DATE].includes(key)) {
                continue;
            }
            if (!date_datum.hasOwnProperty(key)) {
                continue;
            }
            value = date_datum[key];
            if (!mask.has(key)) {
                date_result[KW_REST] += value;
            }
            else {
                date_result[key] = value;
            }
        }
        result.push(date_result);
    });
    return result;
}

// Make toplist from given dataset [{},{},{}]. Works for both get_dataset_dict()
// and get_series_dict().
function make_toplist(data, limit = 20, key_attr = 'key') {
    // Apply limitting only in case there is need to do so.
    if (limit && data.length > limit) {
        // Data may already contain the '__REST__' item, which contains sum of
        // the values not displayed directly. In case of limitting we must
        // pull this item out, add values of all stripped items to it and then
        // place it back.
        filter_result = _filter_rest(data, key_attr);
        result = filter_result[0];
        rest   = filter_result[1];
        // Now reduce the dataset to given limit of items - 1.
        result = result.reduce(function(total, currentItem, currentIndex, arr) {
            if (currentIndex < (limit - 1)) {
                total[0].push(currentItem);
            }
            else {
                total[1] += currentItem['value'];
            }
            return total;
        }, [[], rest]);
        // Put the '__REST__' item back to the dataset.
        if (result[1]) {
            res = {
                ident: KW_REST,
                value: result[1]
            };
            res[key_attr] = KW_REST;
            result[0].push(res);
        }
        data = result[0];
    }
    return data;
}

// Make toplist from given dataset [{},{},{}]. Works for both get_dataset_dict()
// and get_series_dict().
function make_mtoplist(data, limit = 20) {
    topl = make_toplist(data[0]['values'], limit, 'label');
    result = {'key': data[0].key, 'values': topl};
    return [result];
}

// Render pie chart.
function render_chart_pie(chid, chart_data, params = {}) {
    console.log('Rendering PIE chart: ' + chid);

    nv.addGraph(function() {
        var chart = nv.models.pieChart()
            .x(function(d) { return d.key })
            .y(function(d) { return d.value })
            .showLabels(true)
            .showLegend(true)
            .labelThreshold(.05)  //Configure the minimum slice size for labels to show up
            .labelType("key")     //Configure what type of data to show in the label. Can be "key", "value" or "percent"
            .donut(true)          //Turn on Donut mode. Makes pie chart look tasty!
            .donutRatio(0.2)      //Configure how big you want the donut hole size to be.
        ;

        // Parameters may override default value formatter.
        if (params.value_formatter) {
            chart.tooltip.valueFormatter(
                params.value_formatter
            );
        }

        // Select the appropriate SVG element and bind datum to the chart.
        d3.select("#" + chid + " svg")
            .datum(chart_data)
            .transition().duration(350)
            .call(chart);

        nv.utils.windowResize(chart.update);

        register_visualisation(chid, chart);

        return chart;
    });
}

// Render horizontal bar chart.
function render_chart_hbar(chid, chart_data, params = {}) {
    console.log('Rendering HORIZONTAL BAR chart: ' + chid);

    nv.addGraph(function() {
        var chart = nv.models.multiBarHorizontalChart()
            .x(function(d) { return d.label })
            .y(function(d) { return d.value })
            .showValues(true)
            .showControls(false)
            .showLegend(true)
            .legendPosition("bottom")
            .controlsPosition("bottom")
            .margin({left: 150})
            .stacked(false)
        ;

        // Parameters may override default value formatter.
        if (params.value_formatter) {
            chart.tooltip.valueFormatter(
                params.value_formatter
            );
        }

        // Select the appropriate SVG element and bind datum to the chart.
        d3.select("#" + chid + " svg")
            .datum(chart_data)
            .transition().duration(350)
            .call(chart);

        nv.utils.windowResize(chart.update);

        register_visualisation(chid, chart);

        return chart;
    });
}

// Custom color scale function for table rows. First column must inherit
// the color from parent, because datasets start on second column.
function table_row_color_scale() {
	var counter = 0;
	var palette = d3.scale.category20();
	return function() {
		counter += 1;
		if (counter == 1) {
			return 'inherit';
		}
		return palette(counter-1);
	};
}

// Custom color scale function for table columns.
function table_column_color_scale(column) {
    var column_number = column;
    var counter = 0;
    var palette = d3.scale.category20();
    return function(d, i) {
        if (column_number == i) {
            counter += 1;
            return palette(counter-1);
        }
        return 'inherit';
    };
}

// Custom color scale function.
function table_color_scale() {
    var palette = d3.scale.category20();
    return function(d, i) {
        return palette(i);
    };
}

function table_value_formatter(formatter) {
    var value_formatter = formatter;
    return function(value) {
        try {
            return value_formatter(value);
        }
        catch(err) {
            return value;
        }
    }
}

function byteFormatter() {
    var value_formatter = GLOBALIZER.numberFormatter({
        minimumFractionDigits: 2,
        maximumFractionDigits: 2
    });
    var units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
    var step_size = 1024;
    return function(size) {
        var unit = 'B';
        for (var i = 0; i < units.length-1; i++) {
            if (size > step_size) {
                if (unit.toUpperCase() == units[i]) {
                    size = size / step_size;
                    unit = units[i+1];
                }
            }
            else {
                break;
            }
        }
        return '' + value_formatter(size) + ' ' + unit;
    }
}

// Render multi-timeline table.
function render_table_timeline_multi(tid, table_columns, table_data, data_stats) {
	console.log('Rendering table: ' + tid);

    var value_formatter = table_value_formatter(GLOBALIZER.numberFormatter());
    var date_formatter = table_value_formatter(GLOBALIZER.dateFormatter());

    // remove the existing table
    d3.select(`#${tid} > table`).remove();

	var table = d3.select('#' + tid).append('table')
	var thead = table.append('thead')
	var	tbody = table.append('tbody');
    var tfoot = table.append('tfoot');

	table.attr('class', 'table table-bordered table-striped table-condensed table-dataset table-dataset-timeline')

    // Precalculate the sums of all values in each table row. These will be displayed
    // in last table column.
    table_data.map(function(d) {
        d['_sum'] = d3.sum(
            table_columns.slice(1, -1).map(function (column) {
                return d[column.ident];
            })
        );
    });

	// Append the table header row.
	thead.append('tr')
		.selectAll('th')
		.data(table_columns).enter()
		.append('th')
		.style('background-color', table_row_color_scale())
		.text(function (column) { return column.key; });

	// Create a table body row for each object in the data.
	var brows = tbody.selectAll('tr')
		.data(table_data)
		.enter()
		.append('tr');

	// Create a cell in each table body row for each requested column.
	var bcells = brows.selectAll('td')
		.data(function (row) {
		    return table_columns.map(function (column) {
		        return {
                    column: column.ident,
                    value: row[column.ident]
                };
		    });
		})
		.enter()
		.append('td')
		.text(function (d, i) {
            // First column contains the datetime.
            if (i == 0) {
                return date_formatter(new Date(d.value));
            }
            // Other columns contain the numerical values.
            else {
                return value_formatter(d.value);
            }
        });

    // Map of aggregation functions for table footer statistical summaries.
    funcmap = {
        'cnt': function(a,b) { return a.length },
        'min': d3.min,
        'max': d3.max,
        'sum': d3.sum,
        'avg': d3.mean,
        'med': d3.median
    }

    // Create a table footer row for each requested statistical datum.
    var frows = tfoot.selectAll('tr')
        .data(data_stats)
        .enter()
        .append('tr');

    // Create a cell in each table footer row for each requested column except
    // the first one (contains date).
    var fcells = frows.selectAll('td')
        .data(function (row) {
            return [{value: row.key}].concat(
                table_columns.slice(1).map(function (column) {
                    return {
                        column: column.ident,
                        value: d3.nest().rollup(function(rv) {
                            return funcmap[row.ident](rv, function(g) { return g[column.ident] });
                        }).entries(table_data)
                    };
                })
            );
        })
        .enter()
        .append('td')
        .html(function (d, i) {
            // First column contains the HTML label for the statistical operation.
            if (i == 0) {
                return d.value;
            }
            // Other columns contain the values.
            else {
                return '<span>' + value_formatter(d.value) + '</span>';
            }
        });

	return table;
}

// Render context search action dropdown menu.
function render_action_dropdown(actions, args, kwargs) {
    root_elem = document.createElement('div');

    div_slct = d3.select(root_elem)
        .style('display', 'inline-block');

    div_dropdown = div_slct.append('div')
        .attr('class', 'btn-group');

    div_dropdown.append('button')
        .attr('class', 'btn btn-default btn-xs dropdown-toggle')
        .attr('type', 'button')
        .attr('data-toggle', 'dropdown')
        .attr('aria-haspopup', 'true')
        .attr('aria-expanded', 'false')
        .append('span')
            .attr('class', 'caret');

    ul_elem = div_dropdown.append('ul')
        .attr('class', 'dropdown-menu dropdown-menu-right')

    actions.forEach(function(action) {
        try {
            url = Flask.url_for(action.endpoint, action.params(args, kwargs));
            ul_elem.append('li').append('a')
                .attr('href', url)
                .attr('target', '_blank')
                .html(action.icon + action.title.replace('{name}', args));
        }
        catch(err) {
            console.error(err);
        }
    });

    return root_elem;
}

// Render table for dict-based dataset.
function render_table_dict(tid, table_columns, table_data, data_stats, action_list = null, params = {}) {
    console.log('Rendering table: ' + tid);

    var number_formatter = table_value_formatter(
        GLOBALIZER.numberFormatter()
    );
    var value_formatter = number_formatter;

    // Parameters may override default numeric value formatter.
    if (params.value_formatter) {
        value_formatter = table_value_formatter(params.value_formatter);
    }
    var percent_formatter = table_value_formatter(
        GLOBALIZER.numberFormatter({
            style: "percent",
            minimumFractionDigits: 2,
            maximumFractionDigits: 2
        })
    );

    // remove the existing table
    d3.select(`#${tid} > table`).remove();

    var table = d3.select('#' + tid).append('table')
    var thead = table.append('thead')
    var tbody = table.append('tbody');
    var tfoot = table.append('tfoot');

    table.attr('class', 'table table-bordered table-striped table-condensed table-dataset table-dataset-pie')

    // Calculate the grand total.
    total = d3.nest().rollup(function(x) {
        return d3.sum(x, function(y) { return y.value; });
    }).entries(table_data)

    // Precalculate the percentages of all values in each table row. These will be displayed
    // in last table column.
    table_data.forEach(function(d) {
        d.share = d.value/total;
    });

    // Append the table header row.
    thead.append('tr')
        .selectAll('th')
        .data(table_columns).enter()
        .append('th')
        .text(function (column) { return column.label; });

    // Create a table body row for each object in the data.
    var brows = tbody.selectAll('tr')
        .data(table_data)
        .enter()
        .append('tr');

    // Create a cell in each table body row for each requested column.
    var bcells = brows.selectAll('td')
        .data(function (row) {
            return table_columns.map(function (column) {
                return {
                    column: column.ident,
                    value: row[column.ident]
                };
            });
        })
        .enter()
        .append('td');
        //.style('background-color', table_column_color_scale(0));

    bcells.append(function (d, i) {
            element = document.createElement('span');

            // First column contains the name.
            if (i == 0) {
                element.appendChild(
                    document.createTextNode(
                        d.value
                    )
                );
                return element;
            }
            // Second column contains the value.
            if (i == 1) {
                element.appendChild(
                    document.createTextNode(
                        value_formatter(d.value)
                    )
                );
                return element;
            }
            // Last column contains the percentage.
            element.appendChild(
                document.createTextNode(
                    percent_formatter(d.value)
                )
            );
            return element;
        });

    // Insert small circle icon with correct color for given dataset instead of
    // using background color of the whole table cell.
    if (!params.hide_table_colors) {
        brows.select('td')
            .insert('i')
            .style('color', table_color_scale())
            .attr('class', 'fas fa-fw fa-circle pull-left');
    }

    if (action_list) {
        brows.select('td')
            .filter(function(d, i) {
                return !(String(d.key).toLowerCase() in {'__unknown__': true, '__rest__': true});
            })
            .append(function (d, i) {
                return render_action_dropdown(action_list, d.key, params.kwargs);
            });
    }

    // Map of aggregation functions for table footer statistical summaries.
    funcmap = {
        'cnt': function(a,b) { return a.length },
        'min': d3.min,
        'max': d3.max,
        'sum': d3.sum,
        'avg': d3.mean,
        'med': d3.median
    }

    if (params.with_table_stats) {
        // Create a table footer row for each requested statistical datum.
        var frows = tfoot.selectAll('tr')
            .data(data_stats)
            .enter()
            .append('tr');

        // Create a cell in each table footer row for each requested column except
        // the first one (contains name).
        var fcells = frows.selectAll('td')
            .data(function (row) {
                return [{value: row.key}].concat(
                    table_columns.slice(1).map(function (column) {
                        return {
                            column: column.ident,
                            func: row.ident,
                            value: d3.nest().rollup(function(rv) {
                                return funcmap[row.ident](rv, function(g) { return g[column.ident] });
                            }).entries(table_data)
                        };
                    })
                );
            })
            .enter()
            .append('td')
            .html(function (d, i) {
                // First column contains the HTML label for the statistical operation.
                if (i == 0) {
                    return d.value;
                }
                // Second column contains the value, but always use numeric formatter
                // for 'cnt' aggregation.
                if (i == 1 && d.func == 'cnt') {
                    return '<span>' + number_formatter(d.value) + '</span>';
                }
                // Second column contains the value.
                if (i == 1) {
                    return '<span>' + value_formatter(d.value) + '</span>';
                }
                // Last column contains the percentage, but does not make sense for 'cnt' aggregation.
                if (d.func == 'cnt') {
                    return '<i class="fas fa-fw fa-times"></i>';
                }
                // Last column contains the percentage.
                return '<span>' + percent_formatter(d.value) + '</span>';
            });
    }

    return table;
}


// Render table for dict-based dataset.
function render_table_mdict(tid, table_columns, table_data, data_stats, total = null, action_list = null, params = {}) {
    console.log('Rendering table: ' + tid);

    var number_formatter = table_value_formatter(
        GLOBALIZER.numberFormatter()
    );
    var value_formatter = number_formatter;

    // Parameters may override default numeric value formatter.
    if (params.value_formatter) {
        value_formatter = table_value_formatter(params.value_formatter);
    }
    var percent_formatter = table_value_formatter(
        GLOBALIZER.numberFormatter({
            style: "percent",
            minimumFractionDigits: 2,
            maximumFractionDigits: 2
        })
    );

    // remove the existing table
    d3.select(`#${tid} > table`).remove();

    var table = d3.select('#' + tid).append('table')
    var thead = table.append('thead')
    var tbody = table.append('tbody');
    var tfoot = table.append('tfoot');

    table.attr('class', 'table table-bordered table-striped table-condensed table-dataset table-dataset-pie')

    // Calculate the grand total.
    if (total === null) {
        total = d3.nest().rollup(function(x) {
            return d3.sum(x, function(y) { return y.value; });
        }).entries(table_data);
    }

    // Precalculate the percentages of all values in each table row. These will be displayed
    // in last table column.
    table_data.forEach(function(d) {
        d.share = d.value/total;
    });

    // Append the table header row.
    thead.append('tr')
        .selectAll('th')
        .data(table_columns).enter()
        .append('th')
        .text(function (column) { return column.label; });

    // Create a table body row for each object in the data.
    var brows = tbody.selectAll('tr')
        .data(table_data)
        .enter()
        .append('tr');

    // Create a cell in each table body row for each requested column.
    var bcells = brows.selectAll('td')
        .data(function (row) {
            return table_columns.map(function (column) {
                return {
                    column: column.ident,
                    value: row[column.ident]
                };
            });
        })
        .enter()
        .append('td');
        //.style('background-color', table_column_color_scale(0));

    bcells.append(function (d, i) {
            element = document.createElement('span');

            // First column contains the name.
            if (i == 0) {
                element.appendChild(
                    document.createTextNode(
                        d.value
                    )
                );
                return element;
            }
            // Second column contains the value.
            if (i == 1) {
                element.appendChild(
                    document.createTextNode(
                        value_formatter(d.value)
                    )
                );
                return element;
            }
            // Last column contains the percentage.
            element.appendChild(
                document.createTextNode(
                    percent_formatter(d.value)
                )
            );
            return element;
        });

    if (action_list) {
        brows.select('td')
            .filter(function(d, i) {
                return !(String(d.label).toLowerCase() in {'__unknown__': true, '__rest__': true});
            })
            .append(function (d, i) {
                return render_action_dropdown(action_list, d.label, params.kwargs);
            });
    }

    return table;
}