TargetProcess/tauCharts

View on GitHub
src/elements/coords.cartesian.axis.ts

Summary

Maintainability
F
5 days
Test Coverage
import {defaults, take, normalizeAngle} from '../utils/utils';
import {selectOrAppend, classes} from '../utils/utils-dom';
import {cutText, wrapText, avoidTickTextCollision} from '../utils/d3-decorators';
import {CSS_PREFIX} from '../const';
import * as utilsDraw from '../utils/utils-draw';
import {Selection} from 'd3-selection';
import {Transition} from 'd3-transition';

import {AxisLabelGuide, ScaleFunction, ScaleGuide} from '../definitions';

type AxisOrient = 'top' | 'right' | 'bottom' | 'left';
type GridOrient = 'horizontal' | 'vertical';

interface AxisConfig {
    scale: ScaleFunction;
    scaleGuide: ScaleGuide;
    ticksCount?: number;
    tickFormat?: (x) => string;
    tickSize?: number;
    tickPadding?: number;
    gridOnly?: boolean;
    position: [number, number];
}

interface GridConfig {
    scale: ScaleFunction;
    scaleGuide: ScaleGuide;
    ticksCount: number;
    tickSize: number;
    position: [number, number];
}

type d3Selection = Selection<any, any, any, any>;
type d3Transition = Transition<any, any, any, any>;

function identity<T>(x: T) {
    return x;
}

const epsilon = 1e-6;

function translateX(x: number) {
    return `translate(${x + 0.5},0)`;
}

function translateY(y: number) {
    return `translate(0,${y + 0.5})`;
}

function center(scale: ScaleFunction) {
    var offset = Math.max(0, scale.bandwidth() - 1) / 2; // Adjust for 0.5px offset.
    if (scale.round()) {
        offset = Math.round(offset);
    }
    return function (d) {
        return (scale(d) + offset);
    };
}

function getContainer(selection: d3Selection) {
    let svg: SVGElement = selection.node();

    while (svg && svg.tagName !== 'svg') {
        svg = svg.parentNode as SVGElement;
    }

    return svg;
}

const Orient = {
    'top': 1,
    'right': 2,
    'bottom': 3,
    'left': 4
};

function createAxis(config: AxisConfig) {

    const orient = Orient[config.scaleGuide.scaleOrient];
    const scale = config.scale;
    const scaleGuide = config.scaleGuide;
    const labelGuide = scaleGuide.label;
    const {
        ticksCount,
        tickFormat,
        tickSize,
        tickPadding,
        gridOnly
    } = defaults(config, {
            tickSize: 6,
            tickPadding: 3,
            gridOnly: false
        });

    const isLinearScale = (scale.scaleType === 'linear');
    const isOrdinalScale = (scale.scaleType === 'ordinal' || scale.scaleType === 'period');
    const isHorizontal = (orient === Orient.top || orient === Orient.bottom);
    const ko = (orient === Orient.top || orient === Orient.left ? -1 : 1);
    const x = (isHorizontal ? 'x' : 'y');
    const y = (isHorizontal ? 'y' : 'x');
    const transform = (isHorizontal ? translateX : translateY);
    const kh = (isHorizontal ? 1 : -1);

    return ((context: d3Selection | d3Transition) => {

        var values: any[];
        if (scale.ticks) {
            values = scale.ticks(ticksCount);
            // Prevent generating too much ticks
            let count = Math.floor(ticksCount * 1.25);
            while ((values.length > count) && (count > 2) && (values.length > 2)) {
                values = scale.ticks(--count);
            }
        } else {
            values = scale.domain();
        }
        if (scaleGuide.hideTicks) {
            values = gridOnly ? values.filter((d => d == 0)) : [];
        }

        const format = (tickFormat == null ? (scale.tickFormat ? scale.tickFormat(ticksCount) : identity) : tickFormat);
        const spacing = (Math.max(tickSize, 0) + tickPadding);
        const range = scale.range();
        const range0 = (range[0] + 0.5);
        const range1 = (range[range.length - 1] + 0.5);
        let position = (scale.bandwidth ? center : identity)(scale);
        if (scaleGuide.facetAxis) {
            let oldPos = position;
            position = (d) => oldPos(d) - scale.stepSize(d) / 2;
        }
        // Todo: Determine if scale copy is necessary. Fails on ordinal scales with ratio.
        // const position = (scale.bandwidth ? center : identity)(scale.copy());

        const transition = ((context as d3Transition).selection ? (context as d3Transition) : null);
        const selection = (transition ? transition.selection() : context as d3Selection);
        const containerSize = getContainer(selection).getBoundingClientRect();

        // Set default style
        selection
            .attr('fill', 'none')
            .attr('font-size', 10)
            .attr('font-family', 'sans-serif')
            .attr('text-anchor', orient === Orient.right ? 'start' : orient === Orient.left ? 'end' : 'middle');

        interface TickDataBinding {
            tickExit: d3Selection;
            tickEnter: d3Selection;
            tick: d3Selection;
        }

        function drawDomain() {
            const domainLineData = scaleGuide.hideTicks || scaleGuide.hide ? [] : [null];
            take(selection.selectAll('.domain').data(domainLineData))
                .then((path) => {
                    if (transition) {
                        path.exit()
                            .transition(transition)
                            .attr('opacity', 0)
                            .remove();
                    }
                    return path.merge(
                        path.enter().insert('path', '.tick')
                            .attr('class', 'domain')
                            .attr('opacity', 1)
                            .attr('stroke', '#000'));
                })
                .then((path) => {
                    return (transition ?
                        path.transition(transition) :
                        path);
                })
                .then((path) => {
                    path.attr('d', orient === Orient.left || orient == Orient.right ?
                        `M${ko * tickSize},${range0}H0.5V${range1}H${ko * tickSize}` :
                        `M${range0},${ko * tickSize}V0.5H${range1}V${ko * tickSize}`);
                });
        }

        function createTicks(): TickDataBinding {
            return take((selection
                .selectAll('.tick') as d3Selection)
                .data(values, (x) => String(scale(x)))
                .order())

                .then((tick) => {
                    const tickExit = tick.exit<any>();
                    const tickEnter = tick.enter().append('g').attr('class', 'tick');

                    return {
                        tickExit,
                        tickEnter,
                        tick: tick.merge(tickEnter)
                    };
                })
                .then((result) => {
                    if (isLinearScale) {
                        const ticks = scale.ticks();
                        const domain = scale.domain();
                        const last = (values.length - 1);
                        const shouldHighlightZero = (
                            (ticks.length > 1) &&
                            (domain[0] * domain[1] < 0) &&
                            (-domain[0] > (ticks[1] - ticks[0]) / 2) &&
                            (domain[1] > (ticks[last] - ticks[last - 1]) / 2)
                        );
                        result.tick
                            .classed('zero-tick', (d) => {
                                return (
                                    (d == 0) &&
                                    shouldHighlightZero
                                );
                            });
                    }
                    return result;
                })
                .result();
        }

        function updateTicks(ticks: TickDataBinding) {

            take(ticks)

                .then(({tickEnter, tickExit, tick}) => {

                    if (!transition) {
                        return {tick, tickExit};
                    }

                    tickEnter
                        .attr('opacity', epsilon)
                        .attr('transform', function (d) {
                            const p: number = position(d);
                            return transform(p);
                        });

                    return {
                        tick: tick.transition(transition),
                        tickExit: tickExit.transition(transition)
                            .attr('opacity', epsilon)
                            .attr('transform', function (d) {
                                const p = position(d);
                                if (isFinite(p)) {
                                    return transform(p);
                                }
                                return (this as SVGElement).getAttribute('transform');
                            })
                    };

                })

                .then(({tick, tickExit}) => {

                    tickExit.remove();

                    tick
                        .attr('opacity', 1)
                        .attr('transform', (d) => transform(position(d)));

                });
        }

        function drawLines(ticks: TickDataBinding) {
            const ly = (ko * tickSize);
            const lx = (isOrdinalScale ? ((d) => (kh * scale.stepSize(d) / 2)) : null);

            take(ticks)
                .then(({tick, tickEnter}) => {
                    const line = tick.select('line');
                    const lineEnter = tickEnter.append('line')
                        .attr('stroke', '#000')
                        .attr(`${y}2`, ly);

                    if (isOrdinalScale) {
                        lineEnter
                            .attr(`${x}1`, lx)
                            .attr(`${x}2`, lx);
                    }

                    return line.merge(lineEnter);
                })
                .then((line) => {
                    if (transition) {
                        return line.transition(transition);
                    }
                    return line;
                })
                .then((line) => {
                    line
                        .attr(`${y}2`, ly);

                    if (isOrdinalScale) {
                        line
                            .attr(`${x}1`, lx)
                            .attr(`${x}2`, lx);
                    }
                });
        }

        function drawExtraOrdinalLine() {
            if (!isOrdinalScale || !values || !values.length) {
                return;
            }

            take(selection.selectAll('.extra-tick-line').data([null]))
                .then((extra) => {
                    return extra.merge(
                        extra.enter().insert('line', '.tick')
                            .attr('class', 'extra-tick-line')
                            .attr('stroke', '#000'));
                })
                .then((extra) => {
                    return (transition ?
                        extra.transition(transition) :
                        extra);
                })
                .then((extra) => {
                    extra
                        .attr(`${x}1`, range0)
                        .attr(`${x}2`, range0)
                        .attr(`${y}1`, 0)
                        .attr(`${y}2`, ko * tickSize);
                });
        }

        function drawText(ticks: TickDataBinding) {
            const textAnchor = scaleGuide.textAnchor;
            const ty = (ko * spacing);
            const tdy = (orient === Orient.top ? '0em' : orient === Orient.bottom ? '0.71em' : '0.32em');

            function fixTextPosForVerticalFacets(selection) {
                if (scaleGuide.facetAxis) {
                    return selection
                        .attr('dx', -config.position[0] + 18)
                        .attr('dy', 16);
                }
            }

            take(ticks)
                .then(({tick, tickEnter}) => {
                    const text = tick.select('text');
                    const textEnter = tickEnter.append('text')
                        .attr('fill', '#000')
                        .attr(y, ty)
                        .attr('dy', tdy);
                    rotateText(textEnter);
                    fixTextPosForVerticalFacets(textEnter);

                    return text.merge(textEnter);
                })
                .then((text) => {
                    text
                        .text(format)
                        .attr('text-anchor', textAnchor);

                    if (isHorizontal === false && scaleGuide.facetAxis === true) {
                        const facetShift = utilsDraw
                            .parseTransformTranslate(selection.node().parentNode.getAttribute('transform'));

                        fixLongText(text, containerSize.width - Math.abs(facetShift.x));
                    } else {
                        fixLongText(text);
                    }

                    if (isHorizontal && (scale.scaleType === 'time')) {
                        fixHorizontalTextOverflow(text);
                    }
                    if (isHorizontal && (scale.scaleType === 'time' || scale.scaleType === 'linear')) {
                        fixOuterTicksOverflow(text);
                    }

                    return text;
                })
                .then((text) => {
                    if (transition) {
                        return text.transition(transition);
                    }
                    return text;
                })
                .then((text) => {
                    text
                        .attr(y, ty);

                    rotateText(text);
                    fixTextPosForVerticalFacets(text);
                    if (isOrdinalScale && scaleGuide.avoidCollisions && !scaleGuide.facetAxis) {
                        if (transition) {
                            transition.on('end.fixTickTextCollision', () => {
                                return fixTickTextCollision(ticks.tick);
                            });
                        } else {
                            fixTickTextCollision(ticks.tick);
                        }
                    }
                });
        }

        function rotateText(text: d3Selection | d3Transition) {
            const angle = normalizeAngle(scaleGuide.rotate);

            // Todo: Rotate around rotation point (text anchor?)
            text
                .attr('transform', utilsDraw.rotate(angle));

            // Todo: Unpredictable behavior, need review
            if ((Math.abs(angle / 90) % 2) > 0) {
                let kRot = (angle < 180 ? 1 : -1);
                let k = isHorizontal ? 0.5 : -2;
                let sign = (orient === Orient.top || orient === Orient.left ? -1 : 1);
                let dy = (k * (orient === Orient.top || orient === Orient.bottom ?
                    (sign < 0 ? 0 : 0.71) :
                    0.32));

                text
                    .attr('x', 9 * kRot)
                    .attr('y', 0)
                    .attr('dx', isHorizontal ? null : `${dy}em`)
                    .attr('dy', `${dy}em`);
            }
        }

        function fixLongText(text: d3Selection, containerWidth = 0) {
            const stepSize = (d) => Math.max(scale.stepSize(d), scaleGuide.tickFormatWordWrapLimit, containerWidth);

            if (scaleGuide.tickFormatWordWrap) {
                wrapText(
                    text,
                    stepSize,
                    scaleGuide.tickFormatWordWrapLines,
                    scaleGuide.tickFontHeight,
                    !isHorizontal
                );
            } else {
                cutText(
                    text,
                    stepSize
                );
            }
        }

        function fixHorizontalTextOverflow(text: d3Selection) {
            if (values.length < 2) {
                return;
            }
            var maxTextLn = 0;
            var iMaxTexts = -1;
            const nodes: Element[] = text.nodes();
            nodes.forEach((textNode, i) => {
                const textContent = (textNode.textContent || '');
                var textLength = textContent.length;
                if (textLength > maxTextLn) {
                    maxTextLn = textLength;
                    iMaxTexts = i;
                }
            });

            const tickStep = (position(values[1]) - position(values[0]));

            var hasOverflow = false;
            if (iMaxTexts >= 0) {
                var rect = nodes[iMaxTexts].getBoundingClientRect();
                hasOverflow = (tickStep - rect.width) < 8; // 2px from each side
            }
            selection.classed(`${CSS_PREFIX}time-axis-overflow`, hasOverflow);
        }

        function fixOuterTicksOverflow(text: d3Selection) {
            if (values.length === 0) {
                return;
            }

            const value0 = values[0];
            const value1 = values[values.length - 1];

            const tempLeft = selection
                .append('line')
                .attr('x1', position(value0))
                .attr('x2', position(value0))
                .attr('y1', 0)
                .attr('y2', 1) as d3Selection;
            const tempRight = selection
                .append('line')
                .attr('x1', position(value1))
                .attr('x2', position(value1))
                .attr('y1', 0)
                .attr('y2', 1) as d3Selection;
            const available = {
                left: (tempLeft.node().getBoundingClientRect().left - containerSize.left),
                right: (containerSize.right - tempRight.node().getBoundingClientRect().right)
            };
            tempLeft.remove();
            tempRight.remove();

            const fixText = (node: SVGElement, dir: -1 | 1, value) => {
                const rect = node.getBoundingClientRect();
                const side = (dir > 0 ? 'right' : 'left');
                const tx = position(value);
                const limit = available[side];
                const diff = Math.ceil(rect.width / 2 - limit + 1); // 1px rounding fix
                node.setAttribute('dx', String(diff > 0 ? -dir * diff : 0));
            };
            const tick0 = text.filter((d) => d === value0).node();
            const tick1 = text.filter((d) => d === value1).node();
            text.attr('dx', null);
            fixText(tick0, -1, value0);
            fixText(tick1, 1, value1);
        }

        function fixTickTextCollision(tick: d3Selection) {
            avoidTickTextCollision(tick, isHorizontal);
        }

        function drawAxisLabel() {
            const guide = labelGuide;

            const labelTextNode = selectOrAppend(selection, `text.label`)
                .attr('class', classes('label', guide.cssClass))
                .attr('transform', utilsDraw.rotate(guide.rotate))
                .attr('text-anchor', guide.textAnchor);

            take(labelTextNode)
                .then((label) => {
                    if (transition) {
                        return label.transition(transition);
                    }
                    return label;
                })
                .then((label) => {
                    const ly = (kh * guide.padding);
                    const size = Math.abs(range1 - range0);
                    const lx = isHorizontal ? size : 0;

                    label
                        .attr('x', lx)
                        .attr('y', ly);
                });

            const delimiter = ' \u2192 ';
            const textParts = guide.text.split(delimiter);
            for (var i = textParts.length - 1; i > 0; i--) {
                textParts.splice(i, 0, delimiter);
            }

            let tspans = labelTextNode.selectAll('tspan')
                .data(textParts)
                .enter()
                .append('tspan')
                .attr('opacity', epsilon)
                .attr('class', (d, i) => i % 2 ?
                    (`label-token-delimiter label-token-delimiter-${i}`) :
                    (`label-token label-token-${i}`))
                .text((d) => d);

            let tspansExit = tspans
                .exit();

            if (transition) {
                tspans = tspans
                    .transition(transition);

                tspansExit = tspansExit
                    .transition(transition)
                    .attr('opacity', epsilon);
            }

            tspans.attr('opacity', 1);

            tspansExit
                .remove();
        }

        if (!gridOnly) {
            drawDomain();
        }
        const ticks = createTicks();
        updateTicks(ticks);
        if (!scaleGuide.facetAxis) {
            drawLines(ticks);
        }
        if (isOrdinalScale && gridOnly) { // Todo: Explicitly determine if grid
            drawExtraOrdinalLine();
        }
        if (!gridOnly) {
            drawText(ticks);
            if (!labelGuide.hide) {
                drawAxisLabel();
            }
        }

    });
}

export function cartesianAxis(config: AxisConfig) {
    return createAxis(config);
}

export function cartesianGrid(config: GridConfig) {
    return createAxis({
        scale: config.scale,
        scaleGuide: config.scaleGuide,
        ticksCount: config.ticksCount,
        tickSize: config.tickSize,
        gridOnly: true,
        position: config.position,
    });
}