Nhogs/popoto

View on GitHub
src/graph/util/appendFittedText.js

Summary

Maintainability
A
25 mins
Test Coverage
A
100%
import * as d3 from "d3";
import toFunction from "./toFunction";

var CONTEXT_2D = document.createElement("canvas").getContext("2d");
var DEFAULT_CANVAS_LINE_HEIGHT = 12;

export function measureTextWidth(text) {
    return CONTEXT_2D.measureText(text).width;
}

/**
 * Compute the radius of the circle wrapping all the lines.
 *
 * @param lines array of text lines
 * @return {number}
 */
function computeTextRadius(lines) {
    var textRadius = 0;

    for (var i = 0, n = lines.length; i < n; ++i) {
        var dx = lines[i].width / 2;
        var dy = (Math.abs(i - n / 2 + 0.5) + 0.5) * DEFAULT_CANVAS_LINE_HEIGHT;
        textRadius = Math.max(textRadius, Math.sqrt(dx * dx + dy * dy));
    }

    return textRadius;
}

function computeLines(words, targetWidth) {
    var line;
    var lineWidth0 = Infinity;
    var lines = [];

    for (var i = 0, n = words.length; i < n; ++i) {
        var lineText1 = (line ? line.text + " " : "") + words[i];
        var lineWidth1 = measureTextWidth(lineText1);
        if ((lineWidth0 + lineWidth1) / 2 < targetWidth) {
            line.width = lineWidth0 = lineWidth1;
            line.text = lineText1;
        } else {
            lineWidth0 = measureTextWidth(words[i]);
            line = {width: lineWidth0, text: words[i]};
            lines.push(line);
        }
    }

    return lines;
}

function computeTargetWidth(text) {
    return Math.sqrt(measureTextWidth(text.trim()) * DEFAULT_CANVAS_LINE_HEIGHT);
}

/**
 * Split text into words.
 *
 * @param text
 * @return {*|string[]}
 */
function computeWords(text) {
    var words = text.split(/\s+/g); // To hyphenate: /\s+|(?<=-)/
    if (!words[words.length - 1]) words.pop();
    if (!words[0]) words.shift();
    return words;
}

/**
 * Inspired by https://beta.observablehq.com/@mbostock/fit-text-to-circle
 * Extract words from the text and group them by lines to fit a circle.
 *
 * @param text
 * @returns {*}
 */
export function extractLines(text) {
    if (text === undefined || text === null) {
        return [];
    }

    var textString = String(text);

    var words = computeWords(textString);
    var targetWidth = computeTargetWidth(textString);

    return computeLines(words, targetWidth);
}

function createTextElements(selection, getClass) {
    selection.each(function (d) {
        var text = d3.select(this)
            .selectAll(".fitted-text")
            .data([{}]);

        text.enter()
            .append("text")
            .merge(text)
            .attr("class", "fitted-text" + (getClass !== undefined ? " " + getClass(d) : ""))
            .attr("style", "text-anchor: middle; font: 10px sans-serif");
    });
}

function createSpanElements(text, getText) {
    text.each(function (fitData) {
        var lines = extractLines(getText(fitData));
        var span = d3.select(this).selectAll("tspan")
            .data(lines);

        span.exit().remove();

        span.enter()
            .append("tspan")
            .merge(span)
            .attr("x", 0)
            .attr("y", function (d, i) {
                var lineCount = lines.length;
                return (i - lineCount / 2 + 0.8) * DEFAULT_CANVAS_LINE_HEIGHT
            })
            .text(function (d) {
                return d.text
            });
    });
}

/**
 * Create the text representation of a node by slicing the text into lines to fit the node.
 *
 * @param selection
 * @param textParam
 * @param radiusParam
 * @param classParam
 */
export default function appendFittedText(selection, textParam, radiusParam, classParam) {
    var getRadius = toFunction(radiusParam);
    var getText = toFunction(textParam);
    var getClass = classParam ? toFunction(classParam) : classParam;

    createTextElements(selection, getClass);

    var text = selection.select(".fitted-text");

    createSpanElements(text, getText);

    text.attr("transform", function (d) {
        var lines = extractLines(getText(d));
        var textRadius = computeTextRadius(lines);

        var scale = 1;
        if (textRadius !== 0 && textRadius) {
            scale = getRadius(d) / textRadius;
        }
        return "translate(" + 0 + "," + 0 + ")" + " scale(" + scale + ")"
    });
}