TargetProcess/tauCharts

View on GitHub
src/elements/decorators/layer-labels.ts

Summary

Maintainability
F
5 days
Test Coverage
import * as utilsDraw from '../../utils/utils-draw';
import * as utilsDom from '../../utils/utils-dom';
import * as utils from '../../utils/utils';
import {LayerLabelsModel} from './layer-labels-model';
import {LayerLabelsRules} from './layer-labels-rules';
import {AnnealingSimulator} from './layer-labels-annealing-simulator';
import {LayerLabelsPenalties, LabelPenaltyModel} from './layer-labels-penalties';
import {FormatterRegistry} from '../../formatter-registry';
import * as d3SelectionJs from 'd3-selection';

import {
    d3Selection,
    GrammarModel,
    ScaleGuide
} from '../../definitions';

export interface TextInfo {
    data;
    x: number;
    y: number;
    w: number;
    h: number;
    hide: boolean;
    extr: string;
    size: number;
    angle: number;
    label: string;
    color: string;
    i?: number;
}

export interface EdgeInfo {
    x0: number;
    y0: number;
    x1: number;
    y1: number;
}

interface Parallel {
    text: TextInfo[];
    edges: EdgeInfo[];
}

var intersect = (x1, x2, x3, x4, y1, y2, y3, y4) => utilsDraw.isIntersect(
    x1, y1,
    x2, y2,
    x3, y3,
    x4, y4
);

export class LayerLabels {

    container: d3Selection;
    model: GrammarModel;
    flip: boolean;
    w: number;
    h: number;
    guide: ScaleGuide;

    constructor(
        model: GrammarModel,
        isHorizontal: boolean,
        labelGuide: ScaleGuide,
        {width, height, container}: {width?: number, height?: number, container?: d3Selection}
    ) {
        this.container = container;
        this.model = model;
        this.flip = isHorizontal;
        this.w = width;
        this.h = height;
        this.guide = utils.defaults(
            (labelGuide || {}),
            {
                fontFamily: 'Helvetica Neue, Segoe UI, Open Sans, Ubuntu, sans-serif',
                fontWeight: 'normal',
                fontSize: 10,
                fontColor: '#000',
                hideEqualLabels: false,
                lineBreak: false,
                lineBreakSeparator: '',
                position: [],
                tickFormat: null,
                tickFormatNullAlias: ''
            });
    }

    draw(fibers: any[][]) {

        var self = this;

        var model = this.model;
        var guide = this.guide;
        var lineBreakAvailable = guide.lineBreak;
        var lineBreakSeparator = guide.lineBreakSeparator;

        var seed = LayerLabelsModel.seed(
            model,
            {
                fontColor: guide.fontColor,
                flip: self.flip,
                formatter: FormatterRegistry.get(guide.tickFormat, guide.tickFormatNullAlias),
                labelRectSize: (lines) => utilsDom.getLabelSize(lines, guide),
                lineBreakAvailable: lineBreakAvailable,
                lineBreakSeparator: lineBreakSeparator,
                paddingKoeff: lineBreakAvailable ? 0 : 0.5
            });

        var args = {
            lineBreakAvailable: lineBreakAvailable,
            maxWidth: self.w,
            maxHeight: self.h,
            data: fibers.reduce((memo, f) => memo.concat(f), [])
        };
        var m = this.applyFixedPositionRules(guide, args, seed, lineBreakAvailable, this.flip);

        var readBy3 = <T, K>(list: T[], iterator: (a: T, b: T, c: T) => K) => {
            var l = list.length - 1;
            var r: K[] = [];
            for (var i = 0; i <= l; i++) {
                var iPrev = (i === 0) ? i : (i - 1);
                var iCurr = i;
                var iNext = (i === l) ? i : (i + 1);
                r.push(iterator(list[iPrev], list[iCurr], list[iNext]));
            }
            return r;
        };

        var parallel = fibers.reduce(
            (memo, f) => {
                var absFiber = f.map((row) => {
                    return {
                        data: row,
                        x: m.x(row) + m.dx(row),
                        y: m.y(row) + m.dy(row),
                        w: m.w(row),
                        h: m.h(row, args),
                        hide: m.hide(row),
                        extr: null,
                        size: m.model.size(row),
                        angle: m.angle(row),
                        label: m.label(row),
                        labelLinesAndSeparator: m.labelLinesAndSeparator(row),
                        color: m.color(row),
                    };
                });

                memo.text = memo.text.concat(absFiber);
                memo.edges = memo.edges.concat(readBy3(absFiber, (prev, curr, next) => {

                    if (curr.y === Math.max(curr.y, prev.y, next.y)) {
                        curr.extr = 'min';
                    } else if (curr.y === Math.min(curr.y, prev.y, next.y)) {
                        curr.extr = 'max';
                    } else {
                        curr.extr = 'norm';
                    }

                    return {x0: prev.x, x1: curr.x, y0: prev.y, y1: curr.y};
                }));

                return memo;
            },
            <Parallel>{text: [], edges: []});

        parallel.text = parallel.text
            .filter((r) => r.label)
            .map((r, i) => Object.assign(r, {i: i}));

        var positions = lineBreakAvailable ?
            [
                'auto:hide-on-label-label-overlap',
                'auto:adjust-on-multiline-label-overflow'
            ] : this.guide.position;
        var tokens = positions.filter((token) => token.indexOf('auto:avoid') === 0);
        parallel = ((parallel.text.length > 0) && (tokens.length > 0)) ?
            this.autoPosition(parallel, tokens) :
            parallel;

        var flags = positions.reduce((memo, token) => Object.assign(memo, {[token]:true}), {});

        parallel.text = parallel.text = flags['auto:adjust-on-label-overflow'] ?
            this.adjustOnOverflow(parallel.text, args) :
            parallel.text;

        parallel.text = parallel.text = flags['auto:adjust-on-multiline-label-overflow'] ?
            this.adjustOnMultilineOverflow(parallel.text, args) :
            parallel.text;

        parallel.text = flags['auto:hide-on-label-edges-overlap'] ?
            this.hideOnLabelEdgesOverlap(parallel.text, parallel.edges) :
            parallel.text;

        parallel.text = flags['auto:hide-on-label-label-overlap'] ?
            this.hideOnLabelLabelOverlap(parallel.text) :
            parallel.text;

        parallel.text = flags['auto:hide-on-label-anchor-overlap'] ?
            this.hideOnLabelAnchorOverlap(parallel.text) :
            parallel.text;

        var labels = parallel.text;

        var get = ((prop) => ((__, i) => labels[i][prop]));

        var xi = get('x');
        var yi = get('y');
        var angle = get('angle');
        var color = get('color');
        var label = get('label');
        var update = function (elements: d3Selection) {
            elements
                .style('fill', color)
                .style('font-size', `${self.guide.fontSize}px`)
                .style('display', ((__, i) => labels[i].hide ? 'none' : null))
                .attr('class', 'i-role-label')
                .attr('text-anchor', 'middle')
                .attr('transform', (d, i) => `translate(${xi(d, i)},${yi(d, i)}) rotate(${angle(d, i)})`);

            if (lineBreakAvailable) {
                var nextLineDYKoeff = 1.2;

                elements.each(function (d, i) {
                    var d3Label = d3SelectionJs.select(this);
                    var angleValue = angle(d, i);

                    d3Label.text(null);

                    label(d, i)
                        .split(lineBreakSeparator)
                        .forEach(function (word, index) {
                            d3Label
                                .append('tspan')
                                .attr('text-anchor', angleValue !== 0 ? 'start' : 'middle')
                                .attr('x', 0)
                                .attr('y', 0)
                                .attr('dy', (index + 1) * nextLineDYKoeff + 'em')
                                .text(word);
                        });
                });
            } else {
                elements.text(label);
            }
        };

        if (guide.hideEqualLabels) {
            labels
                .filter((d) => !d.hide)
                .filter((d, i, visibleLabels) => (
                    (i < visibleLabels.length - 1) &&
                    (d.label === visibleLabels[i + 1].label)
                ))
                .forEach((d) => d.hide = true);
        }

        var text = this
            .container
            .selectAll('.i-role-label')
            .data(labels.map((r) => r.data));
        text.exit()
            .remove();
        text.call(update);
        text.enter()
            .append('text')
            .call(update);

        return text as d3Selection;
    }

    applyFixedPositionRules(guide, args, seed, lineBreakAvailable, flip) {
        var fixedPosition = guide
            .position
            .filter((token) => token.indexOf('auto:') === -1);

        if (lineBreakAvailable) {
            if (flip) {
                fixedPosition.push('multiline-label-vertical-center-align');
            }

            fixedPosition.push('multiline-label-left-align', 'multiline-hide-on-container-overflow');
        }

        return fixedPosition
            .map(LayerLabelsRules.getRule)
            .reduce((prev, rule) => LayerLabelsModel.compose(prev, rule(prev, args)), seed);
    }

    autoPosition(parallel: Parallel, tokens: string[]) {

        const calcEllipticXY = (r, angle) => {
            const xReserve = 4;
            const yReserve = 2;
            const a = xReserve + (r.size + r.w) / 2;
            const b = yReserve + (r.size + r.h) / 2;
            return {
                x: (a * Math.cos(angle)),
                y: (b * Math.sin(angle))
            };
        };

        var edges = parallel.edges;
        var labels = parallel.text
            .map((r) => {
                const maxAngles = {
                    max: -Math.PI / 2,
                    min: Math.PI / 2,
                    norm: (Math.random() * Math.PI * 2)
                };
                const xy = calcEllipticXY(r, maxAngles[r.extr]);
                return <LabelPenaltyModel>{
                    i: r.i,
                    x0: r.x,
                    y0: r.y,
                    x: r.x + xy.x,
                    y: r.y + xy.y,
                    w: r.w,
                    h: r.h,
                    size: r.size,
                    hide: r.hide,
                    extr: r.extr
                };
            })
            .filter(r => !r.hide);

        var sim = new AnnealingSimulator({
            items: labels,
            transactor: (row) => {
                const prevX = row.x;
                const prevY = row.y;
                return {
                    modify: () => {
                        const maxAngles = {
                            max: -Math.PI,
                            min: Math.PI,
                            norm: Math.PI * 2
                        };
                        const segm = 4;
                        const maxAngle = maxAngles[row.extr];
                        const angle = ((maxAngle / segm) + (Math.random() * (maxAngle * (segm - 2)) / segm));
                        const xy = calcEllipticXY(row, angle);

                        row.x = row.x0 + xy.x;
                        row.y = row.y0 + xy.y;

                        return row;
                    },
                    revert: () => {
                        row.x = prevX;
                        row.y = prevY;
                        return row;
                    }
                };
            },
            penalties: tokens
                .map((token) => LayerLabelsPenalties.get(token))
                .filter(x => x)
                .map((penalty) => penalty(labels, edges))
        });

        const bestRevision = sim.start(5);

        parallel.text = bestRevision.reduce((memo, l) => {
            var r = memo[l.i];
            r.x = l.x;
            r.y = l.y;
            return memo;
        }, parallel.text);

        return parallel;
    }

    hideOnLabelEdgesOverlap(data: TextInfo[], edges: EdgeInfo[]) {

        const penaltyLabelEdgesOverlap = (label: TextInfo, edges: EdgeInfo[]) => {
            const rect = this.getLabelRect(label);
            return edges.reduce((sum, edge) => {
                var overlapTop = intersect(rect.x0, rect.x1, edge.x0, edge.x1, rect.y0, rect.y1, edge.y0, edge.y1);
                var overlapBtm = intersect(rect.x0, rect.x1, edge.x0, edge.x1, rect.y1, rect.y0, edge.y0, edge.y1);
                return sum + (Number(overlapTop) + Number(overlapBtm)) * 2;
            }, 0);
        };

        data.filter((r) => !r.hide)
            .forEach((r) => {
                if (penaltyLabelEdgesOverlap(r, edges) > 0) {
                    r.hide = true;
                }
            });

        return data;
    }

    hideOnLabelLabelOverlap(data: TextInfo[]) {

        var extremumOrder = {min: 0, max: 1, norm: 2};
        var collisionSolveStrategies = {
            'min/min': ((p0, p1) => p1.y - p0.y), // desc
            'max/max': ((p0, p1) => p0.y - p1.y), // asc
            'min/max': (() => -1), // choose min
            'min/norm': (() => -1), // choose min
            'max/norm': (() => -1), // choose max
            'norm/norm': ((p0, p1) => p0.y - p1.y) // asc
        };

        var cross = ((a: TextInfo, b: TextInfo) => {
            var ra = this.getLabelRect(a);
            var rb = this.getLabelRect(b);
            var k = Number(!a.hide && !b.hide);

            var x_overlap = k * Math.max(0, Math.min(rb.x1, ra.x1) - Math.max(ra.x0, rb.x0));
            var y_overlap = k * Math.max(0, Math.min(rb.y1, ra.y1) - Math.max(ra.y0, rb.y0));

            if ((x_overlap * y_overlap) > 0) {
                let p = [a, b];
                p.sort((p0, p1) => extremumOrder[p0.extr] - extremumOrder[p1.extr]);
                let r = (collisionSolveStrategies[`${p[0].extr}/${p[1].extr}`](p[0], p[1]) < 0 ?
                    p[0] :
                    p[1]
                );
                r.hide = true;
            }
        });

        data.filter((r) => !r.hide)
            .sort((p0, p1) => {
                return extremumOrder[p0.extr] - extremumOrder[p1.extr];
            })
            .forEach((a) => {
                data.forEach((b) => {
                    if (a.i !== b.i) {
                        cross(a, b);
                    }
                });
            });

        return data;
    }

    getLabelRect(a: TextInfo, border = 0) {
        return {
            x0: a.x - a.w / 2 - border,
            x1: a.x + a.w / 2 + border,
            y0: a.y - a.h / 2 - border,
            y1: a.y + a.h / 2 + border
        };
    }

    getPointRect(a: TextInfo, border = 0) {
        return {
            x0: a.x - a.size / 2 - border,
            x1: a.x + a.size / 2 + border,
            y0: a.y - a.size / 2 - border,
            y1: a.y + a.size / 2 + border
        };
    }

    hideOnLabelAnchorOverlap(data: TextInfo[]) {

        var isIntersects = ((label, point) => {
            const labelRect = this.getLabelRect(label, 2);
            const pointRect = this.getPointRect(point, 2);

            var x_overlap = Math.max(
                0,
                Math.min(pointRect.x1, labelRect.x1) - Math.max(pointRect.x0, labelRect.x0));

            var y_overlap = Math.max(
                0,
                Math.min(pointRect.y1, labelRect.y1) - Math.max(pointRect.y0, labelRect.y0));

            return (x_overlap * y_overlap) > 0.001;
        });

        data.filter((row) => !row.hide)
            .forEach((label) => {
                const dataLength = data.length;
                for (let i = 0; i < dataLength; i++) {
                    const point = data[i];
                    if ((label.i !== point.i) && isIntersects(label, point)) {
                        label.hide = true;
                        break;
                    }
                }
            });

        return data;
    }

    adjustOnOverflow(data: TextInfo[], {maxWidth, maxHeight}) {
        return data.map((row) => {
            if (!row.hide) {
                row.x = Math.min(Math.max(row.x, row.w / 2), (maxWidth - row.w / 2));
                row.y = Math.max(Math.min(row.y, (maxHeight - row.h / 2)), row.h / 2);
            }
            return row;
        });
    }

    adjustOnMultilineOverflow(data: TextInfo[], {maxWidth}) {
        return data.map((row) => {
            if (!row.hide && row.angle === 0) {
                row.x = Math.min(Math.max(row.x, row.w / 2), (maxWidth - row.w / 2));
            }
            return row;
        });
    }
}