Source

legends.js

/** @module */

import * as _ from 'lodash';

/**
 * @typedef {Object} LegendInfo
 * @description Legend configuration
 * @property {string} title - title of the legend, defaults to palette name
 * @property {string} palette - name of the palette to use
 * @property {string} geom - type of geom to use for legend. If absent, will be copied from the
 *   first column info with the same palette
 * @property {string[]} labels - labels for the legend. _Required_ for `text` and `image` geoms.
 *   For `image` geom is a list of captions for the images. For `text` geom is a list of texts
 *   displayed on the left of the legend. For `pie` geom can be inferred from palette, if palette
 *   is specified as a {@link module:palettes~CustomPalette|CustomPalette}. For numeric geoms
 *   defaults to `['0', '', '0.2', '', '0.4', '', '0.6', '', '0.8', '', '1']`
 * @property {('left'|'center'|'right')} label_align - alignment of the labels. Defaults to `center`
 *   for `circle`, `rect` and `funkyrect` geoms
 * @property {number} label_hjust - numerical alias for `label_align`, converts `0, 0.5, 1` to
 *   `left, center, right`, complains about any other values
 * @property {number|number[]} size - size of the legend elements. _Required_ for `image` geom,
 *   will set the width of the image. For `circle` and `funkyrect` geoms defaults to increasing
 *   values from 0 to 1. For `rect` and `bar` geoms defaults to 1. Must be the same length as
 *   `labels`
 * @property {number|number[]} values - values for the legend elements. For numeric geoms represents
 *   color values for the legend elements. Must be the same length as `labels`. _Required_ for
 *   `image` and `text` geoms. For `image` geom is a list of image URLs. For `text` geom is a list
 *   of texts displayed on the right of the legend
 */

/**
 * Validate user-provided legend options and prepare legends for rendering. A legend is necessary
 * for each palette used in the visualization. If user have not provided legend configuration for
 * a palette that is used in {@link module:columns~ColumnInfo|ColumnInfo}, it will be added
 * automatically.
 *
 * @param {module:legends~LegendInfo[]} legends - user provided legend configuration
 * @param {module:palettes~PaletteMapping} palettes - mapping of names to palette colors
 * @param {module:columns.Column[]} columnInfo - user provided information on columns
 */
export function prepareLegends(legends, palettes, columnInfo) {
    if (legends.length === 0) {
        console.info('No legends provided, will infer automatically');
        legends = [];
    }

    const colInfoPalettes = [];
    columnInfo.forEach(i => {
        if (i.palette && colInfoPalettes.indexOf(i.palette) === -1) {
            colInfoPalettes.push(i.palette);
        }
    });
    const legendPalettes = [];
    legends.forEach(l => {
        if (l.palette && legendPalettes.indexOf(l.palette) === -1) {
            legendPalettes.push(l.palette);
        }
    });

    const missingPalettes = _.difference(colInfoPalettes, legendPalettes);
    if (missingPalettes.length > 0) {
        let msg = 'These palettes are missing in legends, adding legends for them: ';
        msg += missingPalettes.join(', ');
        console.info(msg);
        missingPalettes.forEach(p => {
            legends.push({
                title: p,
                palette: p,
                enabled: true,
            });
        });
    }

    legends.forEach(legend => {
        if (legend.enabled === undefined) {
            legend.enabled = true;
        }
        if (legend.title === undefined) {
            legend.title = legend.palette;
        }
        if (legend.geom === undefined) {
            console.info(`Legend \`${legend.title}\` did not specify geom, copying from column info`);
            const col = columnInfo.find(i => i.palette === legend.palette);
            legend.geom = col.geom;
        }
        if (legend.labels === undefined) {
            console.info(`Legend \`${legend.title}\` did not specify labels, inferring from column info`);
            if (legend.geom === 'pie') {
                const pal = palettes[legend.palette];
                if (pal.names === undefined) {
                    console.warn(`Cannot infer labels for legend \`${legend.title}\`, please provide color names in palette. Disabling this legend`);
                    legend.enabled = false;
                }
                legend.labels = palettes[legend.palette].names;
            } else if (['circle', 'rect', 'funkyrect', 'bar'].includes(legend.geom)) {
                // TODO: get from default options
                legend.labels = ['0', '', '0.2', '', '0.4', '', '0.6', '', '0.8', '', '1'];
            } else if (legend.geom === 'text' || legend.geom === 'image') {
                console.warn(`Cannot infer labels for legend \`${legend.title}\` of type ${legend.geom}, please provide labels. Disabling this legend`);
                legend.enabled = false;
            }
        }
        if (legend.label_hjust !== undefined) {
            if (legend.label_hjust === 0) {
                legend.label_align = 'left';
                console.info(`Converting label_hjust=0 to label_align=left for legend \`${legend.title}\``);
            } else if (legend.label_hjust === 1) {
                legend.label_align = 'right';
                console.info(`Converting label_hjust=1 to label_align=right for legend \`${legend.title}\``);
            } else if (legend.label_hjust === 0.5) {
                legend.label_align = 'center';
                console.info(`Converting label_hjust=0.5 to label_align=center for legend \`${legend.title}\``);
            } else {
                console.warn(`Unsupported value for label_hjust: ${legend.label_hjust} for legend \`${legend.title}\`, ignoring. Only 0, 0.5, 1 are supported`);
            }
        }
        if (legend.label_align === undefined) {
            if (['circle', 'rect', 'funkyrect'].includes(legend.geom)) {
                legend.label_align = 'center';
            }
        }
        if (!['left', 'right', 'center'].includes(legend.label_align)) {
            legend.label_align = 'center';
            console.warn(`Unsupported value for label_align: ${legend.label_align} for legend \`${legend.title}\`, ignoring. Only left, center, right are supported`);
        }
        if (legend.size === undefined) {
            console.info(`Legend \`${legend.title}\` did not specify size, inferring from column info`);
            if (legend.geom === 'circle' || legend.geom === 'funkyrect') {
                legend.size = [...d3.range(0, legend.labels.length - 1).map(
                    (i) => i / (legend.labels.length - 1)
                ), 1];
            } else if (legend.geom === 'rect' || legend.geom === 'bar') {
                legend.size = 1;
            } else if (legend.geom === 'image') {
                throw `Please specify size (width) for image legend \`${legend.title}\``;
            }
        }
        if (legend.values === undefined) {
            if (['circle', 'rect', 'funkyrect', 'bar'].includes(legend.geom)) {
                legend.values = [...d3.range(0, legend.labels.length - 1).map(
                    (i) => i / (legend.labels.length - 1)
                ), 1];
            }
            if (legend.enabled && (legend.geom === 'image' || legend.geom === 'text')) {
                console.warn(`Cannot infer values for legend \`${legend.title}\` of type ${legend.geom}, please provide values. Disabling this legend`);
                legend.enabled = false;
            }
        }
        if (_.isNumber(legend.size)) {
            legend.size = Array(legend.labels.length).fill(legend.size);
        }
        // TODO: make legend class descend from Column
        if (['circle', 'rect', 'funkyrect', 'bar'].includes(legend.geom)) {
            legend.numeric = true;
            let extent = [0, 1];
            [legend.min, legend.max] = extent;
            legend.range = legend.max - legend.min;
            legend.scale = d3.scaleLinear().domain(extent);
        }
    });
    return legends;
}