TargetProcess/tauCharts

View on GitHub
src/elements/element.area.ts

Summary

Maintainability
D
2 days
Test Coverage
import * as d3Color from 'd3-color';
import * as d3Selection from 'd3-selection';
const d3 = {
    ...d3Color,
    ...d3Selection,
};
import {CSS_PREFIX} from '../const';
import * as utils from '../utils/utils';
import * as utilsDraw from '../utils/utils-draw';
import {BasePath} from './element.path.base';
import {getLineClassesByCount} from '../utils/css-class-map';
import {GrammarRegistry} from '../grammar-registry';
import {d3_createPathTween} from '../utils/d3-decorators';
import {getInterpolatorSplineType} from '../utils/path/interpolators/interpolators-registry';
import {getAreaPolygon, getSmoothAreaPath} from '../utils/path/svg/area-path';
import {getClosestPointInfo} from '../utils/utils-position';
import {useFillGapsRule} from '../utils/utils-grammar';
import {
    GrammarElement
} from '../definitions';

const Area = {

    draw: BasePath.draw,
    highlight: BasePath.highlight,
    highlightDataPoints: BasePath.highlightDataPoints,
    addInteraction: BasePath.addInteraction,
    _sortElements: BasePath._sortElements,

    init(xConfig) {

        const config = BasePath.init(xConfig);
        const enableStack = config.stack;

        config.transformRules = [
            config.flip && GrammarRegistry.get('flip'),
            config.guide.obsoleteVerticalStackOrder && GrammarRegistry.get('obsoleteVerticalStackOrder'),
            !enableStack && GrammarRegistry.get('groupOrderByAvg'),
            useFillGapsRule(config),
            enableStack && GrammarRegistry.get('stack')
        ];

        config.adjustRules = [
            ((prevModel, args) => {
                const isEmptySize = prevModel.scaleSize.isEmptyScale();
                const sizeCfg = utils.defaults(
                    (config.guide.size || {}),
                    {
                        defMinSize: 2,
                        defMaxSize: (isEmptySize ? 6 : 40)
                    });
                const params = Object.assign(
                    {},
                    args,
                    {
                        defMin: sizeCfg.defMinSize,
                        defMax: sizeCfg.defMaxSize,
                        minLimit: sizeCfg.minSize,
                        maxLimit: sizeCfg.maxSize
                    });

                return GrammarRegistry.get('adjustStaticSizeScale')(prevModel, params);
            })
        ];

        return config;
    },

    buildModel(screenModel) {

        const baseModel = BasePath.baseModel(screenModel);

        const guide = this.node().config.guide;
        const countCss = getLineClassesByCount(screenModel.model.scaleColor.domain().length);
        const groupPref = `${CSS_PREFIX}area area i-role-path ${countCss} ${guide.cssClass} `;

        baseModel.groupAttributes = {
            class: (fiber) => `${groupPref} ${baseModel.class(fiber[0])} frame`
        };

        const toDirPoint = (d) => ({
            id: screenModel.id(d),
            x: baseModel.x(d),
            y: baseModel.y(d)
        });

        const toRevPoint = (d) => ({
            id: screenModel.id(d),
            x: baseModel.x0(d),
            y: baseModel.y0(d)
        });

        const pathAttributes = {
            fill: (fiber) => baseModel.color(fiber[0]),
            stroke: (fiber) => {
                var colorStr = baseModel.color(fiber[0]);
                if (colorStr.length > 0) {
                    colorStr = d3.rgb(colorStr).darker(1);
                }
                return colorStr;
            }
        };

        baseModel.pathAttributesEnterInit = pathAttributes;
        baseModel.pathAttributesUpdateDone = pathAttributes;

        const isPolygon = (getInterpolatorSplineType(guide.interpolate) === 'polyline');
        baseModel.pathElement = (isPolygon ? 'polygon' : 'path');
        baseModel.anchorShape = 'vertical-stick';

        baseModel.pathTween = {
            attr: (isPolygon ? 'points' : 'd'),
            fn: d3_createPathTween(
                (isPolygon ? 'points' : 'd'),
                (isPolygon ? getAreaPolygon : getSmoothAreaPath),
                [toDirPoint, toRevPoint],
                screenModel.id,
                guide.interpolate
            )
        };

        return baseModel;
    },

    _getBoundsInfo(this: GrammarElement, dots: Element[]) {
        if (dots.length === 0) {
            return null;
        }

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

        const items = dots
            .map((node) => {
                const data = d3.select(node).data()[0];
                const x = screenModel.x(data);
                const y = screenModel.y(data);
                const y0 = screenModel.y0(data);
                const group = screenModel.group(data);

                const item = {node, data, x, y, y0, group};

                return item;
            });

        const bounds = items.reduce(
            (bounds, {x, y, y0}) => {
                bounds.left = Math.min(x, bounds.left);
                bounds.right = Math.max(x, bounds.right);
                bounds.top = Math.min(y, y0, bounds.top);
                bounds.bottom = Math.max(y, y0, 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.y) :
            ((item) => item.x))).sort((a, b) => a - b);
        const groups = ticks.reduce(((obj, value) => (obj[value] = [], obj)), {} as {[x: number]: ElementInfo[]});
        items.forEach((item) => {
            const tick = ticks.find(flip ? ((value) => item.y === value) : ((value) => item.x === value));
            groups[tick].push(item);
        });

        // Put placeholders for missing groups at some ticks
        (() => {
            const groupNames = Object.keys(items.reduce((map, item) => {
                map[item.group] = true;
                return map;
            }, {}));

            // Todo: sort groups by Y (consider missing groups at some ticks)
            const groupIndex = groupNames.reduce((map, g, i) => {
                map[g] = i;
                return map;
            }, {} as {[group: string]: number});

            ticks.forEach((tick) => {
                const current = groups[tick];
                current.sort((a, b) => groupIndex[a.group] - groupIndex[b.group]);
                if (current.length < groupNames.length) {
                    for (var i = 0; i < groupNames.length; i++) {
                        let shouldInsert = false;
                        let y, y0;
                        if (i === current.length) {
                            if (i === 0) {
                                y = y0 = 0;
                            } else {
                                y = y0 = current[i - 1].y;
                            }
                            shouldInsert = true;
                        } else if (current[i].group !== groupNames[i]) {
                            y = y0 = current[i].y0;
                            shouldInsert = true;
                        }
                        if (shouldInsert) {
                            let placeholder: ElementInfo = {
                                x: tick,
                                y,
                                y0,
                                data: null, // Placeholer should not have any data
                                node: null,
                                group: groupNames[i]
                            };
                            current.splice(i, 0, placeholder);
                        }
                    }
                }
            });
        })();

        if (ticks.length === 1) {
            const tree: TreeNode = {
                start: ticks[0],
                end: ticks[0],
                isLeaf: true,
                items: {
                    start: groups[ticks[0]],
                    end: groups[ticks[0]]
                }
            };
            return {bounds, tree};
        }

        const split = (values: number[]): TreeNode => {
            if (values.length === 2) {
                let [start, end] = values;
                return {
                    start,
                    end,
                    isLeaf: true,
                    items: {
                        start: groups[start],
                        end: groups[end]
                    }
                };
            }

            const midIndex = ((values.length % 2 === 0) ?
                (values.length / 2) :
                ((values.length - 1) / 2));
            const middle = values[midIndex];
            return {
                start: values[0],
                end: values[values.length - 1],
                isLeaf: false,
                left: split(values.slice(0, midIndex + 1)),
                right: split(values.slice(midIndex))
            };
        };
        const tree = split(ticks);

        return {bounds, tree};
    },

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

        const cursor = (flip ? (cursorY - translate.y) : (cursorX - translate.x));
        const closestSpan = (function getClosestSpan(span): TreeNode {
            if (span.isLeaf) {
                return span;
            }
            const mid = span.left.end;
            return getClosestSpan(cursor < mid ? span.left : span.right);
        })(tree);
        var kx = (closestSpan.end === closestSpan.start ?
            0 :
            ((cursor - closestSpan.start) / (closestSpan.end - closestSpan.start)));
        if (kx < 0) {
            kx = 0;
        }
        if (kx > 1) {
            kx = 1;
        }
        const interpolated = (() => {
            interface Pair {
                start: ElementInfo;
                end: ElementInfo;
                y: number;
                y0: number;
            }
            const groups = closestSpan.items.start.reduce((map, el) => {
                map[el.group] = {
                    start: el,
                    end: null,
                    y: null,
                    y0: null
                };
                return map;
            }, {} as {[group: string]: Pair});
            closestSpan.items.end.forEach((el) => {
                if (groups[el.group] === undefined) {
                    delete groups[el.group];
                    return;
                }
                groups[el.group].end = el;
            });
            Object.keys(groups).forEach((key) => {
                const g = groups[key];
                if (!g.end) {
                    delete groups[key];
                    return;
                }
                g.y = (g.start.y + kx * (g.end.y - g.start.y));
                g.y0 = (g.start.y0 + kx * (g.end.y0 - g.start.y0));
            });

            return Object.keys(groups).map((g) => groups[g])
                .map((d) => ({
                    y: d.y,
                    y0: d.y0,
                    el: (kx < 0.5 ? d.start : d.end)
                }))
                .filter((d) => d.el.data != null); // Filter-out missing groups placeholders
        })();

        const cy = (cursorY - translate.y);
        const cursorOverItems = interpolated
            .filter((d) => (cy >= d.y && cy <= d.y0));
        const bestMatchItems = (cursorOverItems.length > 0 ?
            cursorOverItems :
            interpolated);

        const bestElements = bestMatchItems.map((d) => d.el)
            .map((el) => {
                const x = (el.x + translate.x);
                const y = (el.y + translate.y);
                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 getClosestPointInfo(cursorX, cursorY, bestElements);
    }
};

interface BoundsInfo {
    bounds: {left; right; top; bottom;};
    tree: TreeNode;
}

interface ElementInfo {
    x: number;
    y: number;
    y0: number;
    data: any;
    group: string;
    node: Element;
}

interface TreeNode {
    start: number;
    end: number;
    isLeaf: boolean;
    left?: TreeNode;
    right?: TreeNode;
    items?: {
        start: ElementInfo[];
        end: ElementInfo[];
    };
}

export {Area};