TargetProcess/tauCharts

View on GitHub
src/elements/element.interval.js

Summary

Maintainability
F
6 days
Test Coverage
import {CSS_PREFIX} from '../const';
import {GrammarRegistry} from '../grammar-registry';
import {LayerLabels} from './decorators/layer-labels';
import {
    d3_animationInterceptor,
    d3_setClasses as classes
} from '../utils/d3-decorators';
import * as utils from '../utils/utils';
import * as utilsDom from '../utils/utils-dom';
import * as utilsDraw from '../utils/utils-draw';
import * as d3 from 'd3-selection';

const d3Data = ((node) => d3.select(node).data()[0]);

const Interval = {

    init(xConfig) {

        const config = Object.assign({}, xConfig);

        config.guide = (config.guide || {});
        config.guide = utils.defaults(
            (config.guide),
            {
                animationSpeed: 0,
                avoidScalesOverflow: true,
                maxHighlightDistance: 32,
                prettify: true,
                sortByBarHeight: true,
                enableColorToBarPosition: (config.guide.enableColorToBarPosition != null ?
                    config.guide.enableColorToBarPosition :
                    !config.stack)
            });

        config.guide.size = utils.defaults(
            (config.guide.size || {}),
            {
                enableDistributeEvenly: true
            });

        config.guide.label = utils.defaults(
            (config.guide.label || {}),
            {
                position: (config.flip ?
                    (config.stack ?
                        [
                            'r-',
                            'l+',
                            'hide-by-label-height-horizontal',
                            'cut-label-horizontal'
                        ] :
                        [
                            'outside-then-inside-horizontal',
                            'auto:hide-on-label-label-overlap'
                        ]
                    ) :
                        (config.stack ?
                                [
                                    'rotate-on-size-overflow',
                                    't-',
                                    'b+',
                                    'hide-by-label-height-vertical',
                                    'cut-label-vertical',
                                    'auto:hide-on-label-label-overlap'
                                ] :
                                [
                                    'rotate-on-size-overflow',
                                    'outside-then-inside-vertical',
                                    'auto:hide-on-label-label-overlap'
                                ]
                        )
                )
            });

        const avoidScalesOverflow = config.guide.avoidScalesOverflow;
        const enableColorPositioning = config.guide.enableColorToBarPosition;
        const enableDistributeEvenly = config.guide.size.enableDistributeEvenly;

        config.transformRules = [
            config.flip && GrammarRegistry.get('flip'),
            config.guide.obsoleteVerticalStackOrder && GrammarRegistry.get('obsoleteVerticalStackOrder'),
            config.stack && GrammarRegistry.get('stack'),
            enableColorPositioning && GrammarRegistry.get('positioningByColor')
        ]
            .filter(x => x);

        config.adjustRules = [
            (enableDistributeEvenly && ((prevModel, args) => {
                const sizeCfg = utils.defaults(
                    (config.guide.size || {}),
                    {
                        defMinSize: config.guide.prettify ? 3 : 0,
                        defMaxSize: config.guide.prettify ? 40 : Number.MAX_VALUE
                    });
                const params = Object.assign(
                    {},
                    args,
                    {
                        defMin: sizeCfg.defMinSize,
                        defMax: sizeCfg.defMaxSize,
                        minLimit: sizeCfg.minSize,
                        maxLimit: sizeCfg.maxSize
                    });

                return GrammarRegistry.get('size_distribute_evenly')(prevModel, params);
            })),
            (
                avoidScalesOverflow &&
                enableDistributeEvenly &&
                ((prevModel, args) => {
                    const params = Object.assign({}, args, {
                        sizeDirection: 'x'
                    });
                    return GrammarRegistry.get('avoidScalesOverflow')(prevModel, params);
                })
            ),
            (config.stack && GrammarRegistry.get('adjustYScale'))
        ].filter(x => x);

        return config;
    },

    addInteraction() {
        const node = this.node();
        const createFilter = ((data, falsy) => ((row) => row === data ? true : falsy));
        node.on('highlight', (sender, filter) => this.highlight(filter));
        node.on('data-hover', ((sender, e) => this.highlight(createFilter(e.data, null))));
    },

    draw() {
        const node = this.node();
        const config = node.config;
        const options = config.options;
        // TODO: hide it somewhere
        options.container = options.slot(config.uid);

        const prettify = config.guide.prettify;
        const baseCssClass = `i-role-element i-role-datum bar ${CSS_PREFIX}bar`;
        const screenModel = node.screenModel;
        const d3Attrs = this.buildModel(screenModel, {prettify, minBarH: 1, minBarW: 1, baseCssClass});
        const createUpdateFunc = d3_animationInterceptor;

        const barX = config.flip ? 'y' : 'x';
        const barY = config.flip ? 'x' : 'y';
        const barH = config.flip ? 'width' : 'height';
        const barW = config.flip ? 'height' : 'width';

        const fibers = screenModel.toFibers();
        const data = fibers
            .reduce((arr, f) => arr.concat(f), []);

        const barClass = d3Attrs.class;
        const updateAttrs = utils.omit(d3Attrs, 'class');
        const bars = options.container.selectAll('.bar')
            .data(data, screenModel.id);
        bars.exit()
            .classed('tau-removing', true)
            .call(createUpdateFunc(
                config.guide.animationSpeed,
                null,
                {
                    [barX]: function () {
                        var d3This = d3.select(this);
                        var x = d3This.attr(barX) - 0;
                        var w = d3This.attr(barW) - 0;
                        return x + w / 2;
                    },
                    [barY]: function () {
                        return this.getAttribute('data-zero');
                    },
                    [barW]: 0,
                    [barH]: 0
                },
                // ((node) => d3.select(node).remove())
                ((node) => {
                    // NOTE: Sometimes nodes are removed after
                    // they re-appear by filter.
                    var el = d3.select(node);
                    if (el.classed('tau-removing')) {
                        el.remove();
                    }
                })
            ));
        bars.call(createUpdateFunc(
            config.guide.animationSpeed,
            null,
            updateAttrs
        ));
        const merged = bars.enter()
            .append('rect')
            .call(createUpdateFunc(
                config.guide.animationSpeed,
                {[barY]: screenModel[`${barY}0`], [barH]: 0},
                updateAttrs
            ))
            .merge(bars)
            .attr('class', barClass)
            .attr('data-zero', screenModel[`${barY}0`]);

        node.subscribe(new LayerLabels(screenModel.model, screenModel.model.flip, config.guide.label, options)
            .draw(fibers));
        const sortByOrder = (() => {
            const order = data.reduce((map, d, i) => {
                map.set(d, i + 1);
                return map;
            }, new Map());
            return (a, b) => {
                const orderA = (order.get(d3Data(a)) || -1);
                const orderB = (order.get(d3Data(b)) || -1);
                return (orderA - orderB);
            };
        })();
        const sortByWidthThenY = ((a, b) => {
            const dataA = d3Data(a);
            const dataB = d3Data(b);
            const widthA = d3Attrs.width(dataA);
            const widthB = d3Attrs.width(dataB);
            if (widthA === widthB) {
                const yA = d3Attrs.y(dataA);
                const yB = d3Attrs.y(dataB);
                if (yA === yB) {
                    return sortByOrder(a, b);
                }
                return (yA - yB);
            }
            return (widthB - widthA);
        });
        const sortByHeightThenX = ((a, b) => {
            const dataA = d3Data(a);
            const dataB = d3Data(b);
            const heightA = d3Attrs.height(dataA);
            const heightB = d3Attrs.height(dataB);
            if (heightA === heightB) {
                const xA = d3Attrs.x(dataA);
                const xB = d3Attrs.x(dataB);
                if (xA === xB) {
                    return sortByOrder(a, b);
                }
                return (xA - xB);
            }
            return (heightB - heightA);
        });

        this._barsSorter = (config.guide.sortByBarHeight ?
            (config.flip ?
                sortByWidthThenY :
                sortByHeightThenX) :
            sortByOrder
        );

        const elementsOrder = {
            rect: 0,
            text: 1
        };
        this._typeSorter = ((a, b) => elementsOrder[a.tagName] - elementsOrder[b.tagName]);

        this._sortElements(this._typeSorter, this._barsSorter);

        node.subscribe(merged);

        this._boundsInfo = this._getBoundsInfo(merged.nodes());
    },

    buildModel(screenModel, {prettify, minBarH, minBarW, baseCssClass}) {

        const barSize = ((d) => {
            var w = screenModel.size(d);
            if (prettify) {
                w = Math.max(minBarW, w);
            }
            return w;
        });

        var model;
        const value = (d) => d[screenModel.model.scaleY.dim];
        if (screenModel.flip) {
            let barHeight = ((d) => Math.abs(screenModel.x(d) - screenModel.x0(d)));
            model = {
                y: ((d) => screenModel.y(d) - barSize(d) * 0.5),
                x: ((d) => {
                    const x = Math.min(screenModel.x0(d), screenModel.x(d));
                    if (prettify) {
                        // decorate for better visual look & feel
                        const h = barHeight(d);
                        const dx = value(d);
                        var offset = 0;

                        if (dx === 0) {offset = 0;}
                        if (dx > 0) {offset = (h);}
                        if (dx < 0) {offset = (0 - minBarH);}

                        const isTooSmall = (h < minBarH);
                        return (isTooSmall) ? (x + offset) : (x);
                    } else {
                        return x;
                    }
                }),
                height: ((d) => barSize(d)),
                width: ((d) => {
                    const h = barHeight(d);
                    if (prettify) {
                        // decorate for better visual look & feel
                        return (value(d) === 0) ? h : Math.max(minBarH, h);
                    }
                    return h;
                })
            };
        } else {
            let barHeight = ((d) => Math.abs(screenModel.y(d) - screenModel.y0(d)));
            model = {
                x: ((d) => screenModel.x(d) - barSize(d) * 0.5),
                y: ((d) => {
                    var y = Math.min(screenModel.y0(d), screenModel.y(d));
                    if (prettify) {
                        // decorate for better visual look & feel
                        const h = barHeight(d);
                        const isTooSmall = (h < minBarH);
                        y = ((isTooSmall && (value(d) > 0)) ? (y - minBarH) : y);
                    }
                    return y;
                }),
                width: ((d) => barSize(d)),
                height: ((d) => {
                    var h = barHeight(d);
                    if (prettify) {
                        // decorate for better visual look & feel
                        h = ((value(d) === 0) ? h : Math.max(minBarH, h));
                    }
                    return h;
                })
            };
        }
        return Object.assign(
            model,
            {
                class: ((d) => `${baseCssClass} ${screenModel.class(d)}`),
                fill: ((d) => screenModel.color(d))
            });
    },

    _sortElements(...sorters) {
        const container = this.node().config.options.container.node();
        utilsDom.sortChildren(container, utils.createMultiSorter(...sorters));
    },

    _getBoundsInfo(bars) {
        if (bars.length === 0) {
            return null;
        }

        const screenModel = this.node().screenModel;
        const {flip} = this.node().config;

        const items = bars
            .map((node) => {
                const data = d3.select(node).data()[0];
                const x = screenModel.x(data);
                const x0 = screenModel.x0(data);
                const y = screenModel.y(data);
                const y0 = screenModel.y0(data);
                const w = Math.abs(x - x0);
                const h = Math.abs(y - y0);
                const cx = ((x + x0) / 2);
                const cy = ((y + y0) / 2);
                const invert = (y > y0);

                const box = {
                    top: (cy - h / 2),
                    right: (cx + w / 2),
                    bottom: (cy + h / 2),
                    left: (cx - w / 2)
                };

                return {node, data, cx, cy, box, invert};
            });

        const bounds = items.reduce(
            (bounds, {box}) => {
                bounds.left = Math.min(box.left, bounds.left);
                bounds.right = Math.max(box.right, bounds.right);
                bounds.top = Math.min(box.top, bounds.top);
                bounds.bottom = Math.max(box.bottom, bounds.bottom);
                return bounds;
            }, {
                left: Number.MAX_VALUE,
                right: Number.MIN_VALUE,
                top: Number.MAX_VALUE,
                bottom: Number.MIN_VALUE
            });

        const ticks = utils.unique(items.map(flip ?
            ((item) => item.cy) :
            ((item) => item.cx))).sort((a, b) => a - b);
        const groups = ticks.reduce(((obj, value) => (obj[value] = [], obj)), {});
        items.forEach((item) => {
            const tick = ticks.find(flip ? ((value) => item.cy === value) : ((value) => item.cx === value));
            groups[tick].push(item);
        });
        const split = (values) => {
            if (values.length === 1) {
                return groups[values];
            }
            const midIndex = Math.ceil(values.length / 2);
            const middle = (values[midIndex - 1] + values[midIndex]) / 2;
            return {
                middle,
                lower: split(values.slice(0, midIndex)),
                greater: split(values.slice(midIndex))
            };
        };
        const tree = split(ticks);

        return {bounds, tree};
    },

    getClosestElement(_cursorX, _cursorY) {
        if (!this._boundsInfo) {
            return null;
        }
        const {bounds, tree} = this._boundsInfo;
        const container = this.node().config.options.container;
        const {flip} = this.node().config;
        const translate = utilsDraw.getDeepTransformTranslate(container.node());
        const cursorX = (_cursorX - translate.x);
        const cursorY = (_cursorY - translate.y);
        const {maxHighlightDistance} = this.node().config.guide;
        if ((cursorX < bounds.left - maxHighlightDistance) ||
            (cursorX > bounds.right + maxHighlightDistance) ||
            (cursorY < bounds.top - maxHighlightDistance) ||
            (cursorY > bounds.bottom + maxHighlightDistance)
        ) {
            return null;
        }

        const measureCursor = (flip ? cursorY : cursorX);
        const valueCursor = (flip ? cursorX : cursorY);
        const isBetween = ((value, start, end) => value >= start && value <= end);
        const closestElements = (function getClosestElements(el) {
            if (Array.isArray(el)) {
                return el;
            }
            return getClosestElements(measureCursor > el.middle ? el.greater : el.lower);
        })(tree)
            .map((el) => {
                const elStart = (flip ? el.box.left : el.box.top);
                const elEnd = (flip ? el.box.right : el.box.bottom);
                const cursorInside = isBetween(valueCursor, elStart, elEnd);
                if (!cursorInside &&
                    (Math.abs(valueCursor - elStart) > maxHighlightDistance) &&
                    (Math.abs(valueCursor - elEnd) > maxHighlightDistance)
                ) {
                    return null;
                }
                const distToValue = Math.abs(valueCursor - ((el.invert !== flip) ? elEnd : elStart));
                return Object.assign(el, {distToValue, cursorInside});
            })
            .filter((el) => el)
            .sort(((a, b) => {
                if (a.cursorInside !== b.cursorInside) {
                    return (b.cursorInside - a.cursorInside);
                }
                return (Math.abs(a.distToValue) - Math.abs(b.distToValue));
            }))
            .map((el) => {
                const x = (el.cx);
                const y = (el.cy);
                const distance = Math.abs(flip ? (cursorY - y) : (cursorX - x));
                const secondaryDistance = Math.abs(flip ? (cursorX - x) : (cursorY - y));
                return {node: el.node, data: el.data, distance, secondaryDistance, x, y};
            });

        return (closestElements[0] || null);
    },

    highlight(filter) {

        const x = 'tau-chart__highlighted';
        const _ = 'tau-chart__dimmed';

        const container = this.node().config.options.container;
        const classed = {
            [x]: ((d) => filter(d) === true),
            [_]: ((d) => filter(d) === false)
        };

        container
            .selectAll('.bar')
            .call(classes(classed));

        container
            .selectAll('.i-role-label')
            .call(classes(classed));

        this._sortElements(
            (a, b) => (filter(d3Data(a)) - filter(d3Data(b))),
            this._typeSorter,
            this._barsSorter
        );
    }
};

export {Interval};