bar.mjs

/**
 * @module bar
 */
import * as common from './common.mjs';
import * as colorMod from './color.mjs';


/**
 * @typedef {object} BarChartOptions
 * @property {number} [barPadding=6]
 * @property {number} [barRadius=4]
 */


/**
 * @extends module:common.Chart
 * @param {BarChartOptions|module:common~ChartOptions} [options]
 */
export class BarChart extends common.Chart {

    resampleThreshold = null;

    init(options={}) {
        this.barPadding = options.barPadding ?? 6;
        this.barRadius = options.barRadius ?? 4;
        this._bars = new Map();
        this._barsPendingRemoval = new Map();
        this._barFills = new Map();
    }

    beforeSetElement(el) {
        el.classList.add('sc-barchart');
    }

    afterSetElement(el) {
        const barsClipId = `bars-clip-${this.id}`;
        const defs = common.createSVG({
            name: 'defs',
            children: [{
                name: 'clipPath',
                id: barsClipId,
                children: [{
                    name: 'rect',
                    class: ['sc-bars-clip']
                }]
            }]
        });
        this._barsEl = common.createSVG({
            name: 'g',
            class: 'sc-bars',
            attrs: {
                'clip-path': `url(#${barsClipId})`,
            }
        });
        this._plotRegionEl.append(defs, this._barsEl);
    }

    doReset() {
        this._prevXRange = null;
        this._prevYRange = null;
        this._barsEl.replaceChildren();
        this._bars.clear();
        this._barsPendingRemoval.clear();
        for (const x of this._barFills.values()) {
            this.removeGradient(x.gradient);
        }
        this._barFills.clear();
    }

    doLayout(manifest, options) {
        this._renderDoLayout(this._renderBeforeLayout(manifest, options), options);
        this._prevXRange = [this._xMin, this._xMax];
        this._prevYRange = [this._yMin, this._yMax];
        this._schedGC();
    }

    normalizeData(data) {
        // Convert to width and height to center-x and top-y..
        const norm = new Array(data.length);
        if (!data.length) {
            return norm;
        }
        if (Array.isArray(data[0])) {
            // [[width, y], [width, y], ...]
            const width = data[0][0] || 0;
            norm[0] = {index: 0, width, x: width / 2, y: data[0][1] || 0, ref: data[0]};
            let offt = width;
            for (let i = 1; i < data.length; i++) {
                const o = data[i];
                const width = o[0] || 0;
                offt += width;
                norm[i] = {index: i, width, x: offt - (width / 2), y: o[1] || 0, ref: o};
            }
        } else if (typeof data[0] === 'object' && Object.getPrototypeOf(data[0]) === Object.prototype) {
            // [{width, y, ...}, {width, y, ...}, ...]
            const width = data[0].width || 0;
            norm[0] = {...data[0], index: 0, width, x: width / 2, y: data[0].y || 0, ref: data[0]};
            let offt = width;
            for (let i = 1; i < data.length; i++) {
                const o = data[i];
                const width = o.width || 0;
                offt += width;
                norm[i] = {...o, index: i, width, x: offt - (width / 2), y: o.y || 0, ref: o};
            }
        } else {
            // [y, y1, ...]
            let convWarn = 0;
            for (let i = 0; i < data.length; i++) {
                // Importantly, we need unique objects for the data ref and not primatives..
                // Option 1: do not allow this, throw TypeError
                // Option 2: alter the users data (they did give it to us)
                let y;
                if (typeof data[i] === 'number') {
                    convWarn++;
                    y = data[i];
                    data[i] = new Number(y);
                } else {
                    y = +data[i];
                }
                norm[i] = {index: i, width: 1, x: i + 0.5, y: y || 0, ref: data[i]};
            }
            if (convWarn) {
                console.warn(`Converted ${convWarn} primative numbers to unique objects.`);
            }
        }
        return norm;
    }

    adjustScale(manifest) {
        super.adjustScale(manifest);
        if (this._yMax === this._yMin) {
            this._yMin -= 1;
        }
        if (this._xMin == null) {
            this._xMin = 0;
        }
        if (this._xMax == null) {
            const last = this.normalizedData[this.normalizedData.length - 1];
            this._xMax = last.x + last.width / 2;
        }
    }

    _renderBeforeLayout({data}, options={}) {
        const unclaimed = new Map(this._bars);
        const layout = {
            add: [],
            remove: [],
            update: [],
        };
        const yMinCoord = this.yValueToCoord(Math.max(0, this._yMin));
        const adding = [];
        for (let index = 0; index < data.length; index++) {
            const entry = data[index];
            const x1 = this.xValueToCoord(entry.x - entry.width / 2);
            const x2 = this.xValueToCoord(entry.x + entry.width / 2);
            const y = this.yValueToCoord(entry.y);
            const height = yMinCoord - y;
            const color = entry.color || this.getColor();
            const fillKey = `${color}-${height < 0 ? 'down' : 'up'}`;
            let barFill = this._barFills.get(fillKey);
            if (!barFill) {
                const fill = colorMod.parse(color);
                const gradient = this.addGradient((fill instanceof colorMod.Gradient) ? fill : {
                    rotate: height < 0 ? 180 : 0,
                    type: 'linear',
                    colors: [
                        fill.adjustAlpha(-0.5).adjustLight(-0.2),
                        fill.adjustAlpha(-0.14),
                    ]
                });
                this._barFills.set(fillKey, barFill = {gradient});
            }
            const attrs = {
                width: x2 - x1,
                height,
                x: x1,
                y,
                fill: `url(#${barFill.gradient.id})`,
            };
            let bar = this._bars.get(entry.ref);
            if (!bar) {
                bar = this._barsPendingRemoval.get(entry.ref);
                if (bar) {
                    bar.sig = null;
                    this._barsPendingRemoval.delete(entry.ref);
                    this._bars.set(entry.ref, bar);
                }
            } else {
                unclaimed.delete(entry.ref);
            }
            if (!bar) {
                bar = {};
                adding.push({bar, ref: entry.ref});
            }
            const sig = `${attrs.width} ${attrs.height} ${attrs.x} ${attrs.y} ${attrs.fill}`;
            if (bar.sig !== sig) {
                bar.sig = sig;
                bar.attrs = attrs;
                bar.fillKey = fillKey;
                if (bar.el) {
                    layout.update.push(bar);
                }
            }
        }
        for (let i = 0; i < adding.length; i++) {
            const {bar, ref} = adding[i];
            bar.el = common.createSVG({name: 'path', class: ['sc-bar', 'sc-visual-data-bar']});
            this._bars.set(ref, bar);
            layout.add.push(bar);
            layout.update.push(bar);
        }
        for (const [key, bar] of unclaimed) {
            bar.lastUsed = document.timeline.currentTime;
            this._bars.delete(key);
            this._barsPendingRemoval.set(key, bar);
            layout.remove.push(bar);
        }
        if (options.disableAnimation) {
            // Terminate any active animations from previously removed bars (test case: aggressive resizing)
            for (const bar of this._barsPendingRemoval.values()) {
                layout.remove.push(bar);
            }
        }
        return layout;
    }

    /**
     * @param {number} value
     * @param {Array<number>} domain - [xMin, xMax] to use for coordinate scheme
     * @returns {number}
     */
    xValueToCoordUsing(value, domain) {
        domain ??= [this._xMin, this._xMax];
        return (value - domain[0]) * (this._plotWidth / (domain[1] - domain[0])) + this._plotBox[3];
    }

    /**
     * @param {number} value
     * @param {Array<number>} domain - [xMin, xMax] to use for coordinate scheme
     * @returns {number}
     */
    yValueToCoordUsing(value, domain) {
        domain ??= [this._yMin, this._yMax];
        return this._plotBox[2] - ((value - domain[0]) * (this._plotHeight / (domain[1] - domain[0])));
    }

    _renderDoLayout(layout, {disableAnimation}={}) {
        const baselineY = this.yValueToCoord(Math.max(0, this._yMin));
        const shiftY = this._prevXRange ?
            this.yValueToCoordUsing(Math.max(0, this._yMin), this._prevYRange) - baselineY :
            0;
        for (let i = 0; i < layout.add.length; i++) {
            const {el, attrs} = layout.add[i];
            if (!disableAnimation) {
                const centerX = attrs.x + attrs.width / 2;
                el.setAttribute('d', this._makeBarPath(centerX, baselineY + shiftY, 0, 0));
            }
            this._barsEl.append(el);
        }
        if ((!disableAnimation && layout.add.length) || disableAnimation) {
            this._rootSvgEl.clientWidth;
        }
        for (let i = 0; i < layout.update.length; i++) {
            const {el, attrs} = layout.update[i];
            const width = Math.max(0, attrs.width - this.barPadding);
            const x = attrs.x + this.barPadding / 2;
            el.setAttribute('d', this._makeBarPath(x, attrs.y, width, attrs.height));
            el.setAttribute('fill', attrs.fill);
        }
        for (let i = 0; i < layout.remove.length; i++) {
            const {attrs, el} = layout.remove[i];
            if (!disableAnimation) {
                const centerX = attrs.x + attrs.width / 2;
                el.setAttribute('d', this._makeBarPath(centerX, baselineY, 0, 0));
            } else {
                el.removeAttribute('d');
            }
        }
    }

    _makeBarPath(x, y, width, height) {
        const radius = Math.min(this.barRadius, width * 0.5, Math.abs(height));
        const rCtrl = height < 0 ? -radius : radius;
        return (
            `M ${x},${y + height} ` +
            `v ${-height + rCtrl} ` +
            `q 0,${-rCtrl} ${radius},${-rCtrl} ` +
            `h ${width - (2 * radius)} ` +
            `q ${radius},0 ${radius},${rCtrl} ` +
            `v ${height - rCtrl} Z`
        );
    }

    _schedGC() {
        if (this._gcTimeout) {
            return;
        }
        this._gcTimeout = setTimeout(() => {
            common.requestIdle(() => {
                this._gcTimeout = null;
                this._gc();
            });
        }, 1100);
    }

    _gc() {
        const animDur = common.getStyleValue(this.el, '--transition-duration', 'time') || 100;
        const unclaimedFills = new Set(this._barFills.keys());
        for (const x of this._bars.values()) {
            unclaimedFills.delete(x.fillKey);
        }
        const expiration = document.timeline.currentTime - animDur - 100;
        let more;
        for (const [key, bar] of this._barsPendingRemoval) {
            if (bar.lastUsed > expiration) {
                more = true;
                unclaimedFills.delete(bar.fillKey);
            } else {
                this._barsPendingRemoval.delete(key);
                bar.el.remove();
                bar.el = null;
            }
        }
        for (const x of unclaimedFills) {
            const {gradient} = this._barFills.get(x);
            this.removeGradient(gradient);
            this._barFills.delete(x);
        }
        if (more) {
            this._schedGC();
        }
    }
}