Как показать полный текст при масштабировании и усечении при уменьшении

Я создаю древовидную диаграмму с d3.js, она отлично работает... но я хочу, чтобы текст реагировал на масштабирование. Вот JSFiddle.

Посмотрите сначала node... у него много символов (в моем случае max будет 255)

При увеличении или уменьшении моего текста текст остается таким же, но я хочу видеть все при увеличении.

var json = {
  "name": "Maude Charlotte Licia  Fernandez Maude Charlotte Licia  Fernandez Maude Charlotte Licia  Fernandez Maude Charlotte Licia  FernandezMaude Charlotte Licia  Fernandez Maude Charlotte Licia  Fernandez Maude Charlotte Licia  Fernandez Maude asdlkhkjh asd asdsd",
  "id": "06ada7cd-3078-54bc-bb87-72e9d6f38abf",
  "_parents": [{
    "name": "Janie Clayton Norton",
    "id": "a39bfa73-6617-5e8e-9470-d26b68787e52",
    "_parents": [{
      "name": "Pearl Cannon",
      "id": "fc956046-a5c3-502f-b853-d669804d428f",
      "_parents": [{
        "name": "Augusta Miller",
        "id": "fa5b0c07-9000-5475-a90e-b76af7693a57"
      }, {
        "name": "Clayton Welch",
        "id": "3194517d-1151-502e-a3b6-d1ae8234c647"
      }]
    }, {
      "name": "Nell Morton",
      "id": "06c7b0cb-cd21-53be-81bd-9b088af96904",
      "_parents": [{
        "name": "Lelia Alexa Hernandez",
        "id": "667d2bb6-c26e-5881-9bdc-7ac9805f96c2"
      }, {
        "name": "Randy Welch",
        "id": "104039bb-d353-54a9-a4f2-09fda08b58bb"
      }]
    }]
  }, {
    "name": "Helen Donald Alvarado",
    "id": "522266d2-f01a-5ec0-9977-622e4cb054c0",
    "_parents": [{
      "name": "Gussie Glover",
      "id": "da430aa2-f438-51ed-ae47-2d9f76f8d831",
      "_parents": [{
        "name": "Mina Freeman",
        "id": "d384197e-2e1e-5fb2-987b-d90a5cdc3c15"
      }, {
        "name": "Charlotte Ahelandro Martin",
        "id": "ea01728f-e542-53a6-acd0-6f43805c31a3"
      }]
    }, {
      "name": "Jesus Christ Pierce",
      "id": "bfd1612c-b90d-5975-824c-49ecf62b3d5f",
      "_parents": [{
        "name": "Donald Freeman Cox",
        "id": "4f910be4-b827-50be-b783-6ba3249f6ebc"
      }, {
        "name": "Alex Fernandez Gonzales",
        "id": "efb2396d-478a-5cbc-b168-52e028452f3b"
      }]
    }]
  }]
};

var boxWidth = 250,
  boxHeight = 100;

// Setup zoom and pan
var zoom = d3.behavior.zoom()
  .scaleExtent([.1, 1])
  .on('zoom', function() {
    svg.attr("transform", "translate(" + d3.event.translate + ") scale(" + d3.event.scale + ")");
  })
  // Offset so that first pan and zoom does not jump back to the origin
  .translate([600, 600]);

var svg = d3.select("body").append("svg")
  .attr('width', 1000)
  .attr('height', 500)
  .call(zoom)
  .append('g')
  // Left padding of tree so that the whole root node is on the screen.
  // TODO: find a better way
  .attr("transform", "translate(150,200)");

var tree = d3.layout.tree()
  // Using nodeSize we are able to control
  // the separation between nodes. If we used
  // the size parameter instead then d3 would
  // calculate the separation dynamically to fill
  // the available space.
  .nodeSize([100, 200])
  // By default, cousins are drawn further apart than siblings.
  // By returning the same value in all cases, we draw cousins
  // the same distance apart as siblings.
  .separation(function() {
    return .9;
  })
  // Tell d3 what the child nodes are. Remember, we're drawing
  // a tree so the ancestors are child nodes.
  .children(function(person) {
    return person._parents;
  });

var nodes = tree.nodes(json),
  links = tree.links(nodes);

// Style links (edges)
svg.selectAll("path.link")
  .data(links)
  .enter().append("path")
  .attr("class", "link")
  .attr("d", elbow);

// Style nodes    
var node = svg.selectAll("g.person")
  .data(nodes)
  .enter().append("g")
  .attr("class", "person")
  .attr("transform", function(d) {
    return "translate(" + d.y + "," + d.x + ")";
  });

// Draw the rectangle person boxes
node.append("rect")
  .attr({
    x: -(boxWidth / 2),
    y: -(boxHeight / 2),
    width: boxWidth,
    height: boxHeight
  });

// Draw the person name and position it inside the box
node.append("text")
  .attr("text-anchor", "start")
  .attr('class', 'name')
  .text(function(d) {
    return d.name;
  });

// Text wrap on all nodes using d3plus. By default there is not any left or
// right padding. To add padding we would need to draw another rectangle,
// inside of the rectangle with the border, that represents the area we would
// like the text to be contained in.
d3.selectAll("text").each(function(d, i) {
  d3plus.textwrap()
    .container(d3.select(this))
    .valign("middle")
    .draw();
});


/**
 * Custom path function that creates straight connecting lines.
 */
function elbow(d) {
  return "M" + d.source.y + "," + d.source.x + "H" + (d.source.y + (d.target.y - d.source.y) / 2) + "V" + d.target.x + "H" + d.target.y;
}
body {
  text-align: center;
}
svg {
  margin-top: 32px;
  border: 1px solid #aaa;
}
.person rect {
  fill: #fff;
  stroke: steelblue;
  stroke-width: 1px;
}
.person {
  font: 14px sans-serif;
}
.link {
  fill: none;
  stroke: #ccc;
  stroke-width: 1.5px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3plus/1.8.0/d3plus.min.js"></script>

Ответы

Ответ 1

Код в этот jsfiddle является попыткой решить проблемы с производительностью, которые у вас есть с очень большими древовидными диаграммами. Задержка устанавливается с помощью setTimeout в обработчике события масштабирования, чтобы обеспечить масштабирование на "полной скорости" без изменения размера текста. Как только масштабирование остановится на короткое время, текст будет изменен в соответствии с новым масштабированием:

var scaleValue = 1;
var refreshTimeout;
var refreshDelay = 0;

var zoom = d3.behavior.zoom()
    .scaleExtent([.1, 1.5])
    .on('zoom', function () {
        svg.attr("transform", "translate(" + d3.event.translate + ") scale(" + d3.event.scale + ")");
        scaleValue = d3.event.scale;
        if (refreshTimeout) {
            clearTimeout(refreshTimeout);
        }
        refreshTimeout = setTimeout(function () {
            wrapText();
        }, refreshDelay);
    })

Задержка (в миллисекундах) зависит от количества узлов в дереве. Вы можете поэкспериментировать с математическим выражением, чтобы найти наилучшие параметры для широкого диапазона подсчетов node, которые вы ожидаете в своем дереве.

// Calculate the refresh delay
refreshDelay = Math.pow(node.size(), 0.5) * 2.0;

Вы также можете установить параметры в calcFontSize в соответствии с вашими потребностями:

// Calculate the font size for the current scaling
var calcFontSize = function () {
    return Math.min(24, 10 * Math.pow(scaleValue, -0.25))
}

Инициализация узлов была слегка изменена:

node.append("rect")
    .attr({
        x: 0,
        y: -(boxHeight / 2),
        width: boxWidth,
        height: boxHeight
    });

node.append("text")
    .attr("text-anchor", "start")
    .attr("dominant-baseline", "middle")
    .attr('class', 'name')
    .text(function (d) {
        return d.name;
    });

И текст обрабатывается в wrapText:

// Adjust the font size to the zoom level and wrap the text in the container
var wrapText = function () {
    d3.selectAll("text").each(function (d, i) {
        var $text = d3.select(this);
        if (!$text.attr("data-original-text")) {
            // Save original text in custom attribute
            $text.attr("data-original-text", $text.text());
        }
        var content = $text.attr("data-original-text");
        var tokens = content.split(/(\s)/g);
        var strCurrent = "";
        var strToken = "";
        var box;
        var lineHeight;
        var padding = 4;
        $text.text("").attr("font-size", calcFontSize());
        var $tspan = $text.append("tspan").attr("x", padding).attr("dy", 0);
        while (tokens.length > 0) {
            strToken = tokens.shift();
            $tspan.text((strCurrent + strToken).trim());
            box = $text.node().getBBox();
            if (!lineHeight) {
                lineHeight = box.height;
            }
            if (box.width > boxWidth - 2 * padding) {
                $tspan.text(strCurrent.trim());
                if (box.height + lineHeight < boxHeight) {
                    strCurrent = strToken;
                    $tspan = $text.append("tspan").attr("x", padding).attr("dy", lineHeight).text(strCurrent.trim());
                } else {
                    break;
                }
            }
            else {
                strCurrent += strToken;
            }
        }
        $text.attr("y", -(box.height - lineHeight) / 2);
    });
}

Ответ 2

Я сделал образец вашего требования в этом fiddle

Возможно, потребуется изменить настройки текста по вертикали; но это может быть основой для вас. Вычисления выполняются в функции wrap() и вызывают загрузку и масштабирование страницы.

function wrap() {
  var texts = d3.selectAll("text"),
    lineHeight = 1.1, // ems
    padding = 2, // px
    fSize = scale > 1 ? fontSize / scale : fontSize,
    // find how many lines can be included
    lines = Math.floor((boxHeight - (2 * padding)) / (lineHeight * fSize)) || 1;
  texts.each(function(d, i) {
    var text = d3.select(this),
      words = d.name.split(/\s+/).reverse(),
      word,
      line = [],
      lineNumber = 0,
      tspan = text.text(null).append("tspan").attr("dy", "-0.5em").style("font-size", fSize + "px");
    while ((word = words.pop())) {
      line.push(word);
      tspan.text(line.join(" "));
      // check if the added word can fit in the box
      if ((tspan.node().getComputedTextLength() + (2 * padding)) > boxWidth) {
        // remove current word from line
        line.pop();
        tspan.text(line.join(" "));
        lineNumber++;
        // check if a new line can be placed
        if (lineNumber > lines) {
          // left align text of last line
          tspan.attr("x", (tspan.node().getComputedTextLength() - boxWidth) / 2 + padding);
          --lineNumber;
          break;
        }
        // create new line
        tspan.text(line.join(" "));
        line = [word]; // place the current word in new line
        tspan = text.append("tspan")
          .style("font-size", fSize + "px")
          .attr("dy", "1em")
          .text(word);
      }
      // left align text
      tspan.attr("x", (tspan.node().getComputedTextLength() - boxWidth) / 2 + padding);
    }
    // align vertically inside the box
    text.attr("text-anchor", "middle").attr("y", padding - (lineHeight * fSize * lineNumber) / 2);
  });
}

Также обратите внимание, что я добавил стиль dominant-baseline: hanging; в .person class

Ответ 3

Текстовая упаковка может быть интенсивной, если у нас много текста. Чтобы решить эти проблемы, откройте мой первый ответ, эта новая версия улучшил производительность благодаря предварительным рендерингам.

Этот script создает элемент вне DOM и сохраняет в нем все узлы и ребра. Затем он проверяет, какие элементы будут видны, удаляя их из DOM и добавляя их, когда это необходимо.

Я использую jQuery для data() и для выбора элементов. В моем примере на скрипке есть 120 узлов. Но он должен работать аналогично гораздо больше, так как только отображаемые узлы отображаются на экране.

Я изменил режим масштабирования, так что зум сосредоточен на курсоре мыши и был удивлен, увидев, что панорамирование/масштабирование работает и на iOS.

Посмотрите в действии.

UPDATE

Я применил тайм-аут (решение ConnorsFan), поскольку он имеет большое значение. Кроме того, я добавил минимальную шкалу, для которой текст должен быть повторно обернут.

$(function() {

    var viewport_width = $(window).width(),
        viewport_height = $(window).height(),
        node_width = 120,
        node_height = 60,
        separation_width = 100,
        separation_height = 55,
        node_separation = 0.78,
        font_size = 20,
        refresh_delay = 200,
        refresh_timeout,

        zoom_extent = [0.5, 1.15],

        // Element outside DOM, to calculate pre-render
        buffer = $("<div>");

    // Parse "transform" attribute
    function parse_transform(input_string) {
        var transformations = {},
            matches, seek;
        for (matches in input_string = input_string.match(/(\w+)\(([^,)]+),?([^)]+)?\)/gi)) {
            seek = input_string[matches].match(/[\w.\-]+/g), transformations[seek.shift()] = seek;
        }
        return transformations;
    }

    // Adapted from ConnorsFan answer
    function get_font_size(scale) {
        fs = ~~Math.min(font_size, 15 * Math.pow(scale, -0.25));
        fs = ~~(((font_size / scale) + fs) / 2)
        return [fs, fs]
    }

    // Use d3plus to wrap the text
    function wrap_text(scale) {
        if (scale > 0.75) {
            $("svg > g > g").each(function(a, b) {
                f = $(b);
                $("text", f)
                    .text(f.data("text"));
            });
            d3.selectAll("text").each(function(a, b) {
                d3_el = d3.select(this);

                d3plus.textwrap()
                    .container(d3_el)
                    .align("center")
                    .valign("middle")
                    .width(node_width)
                    .height(node_height)
                    .valign("middle")
                    .resize(!0)
                    .size(get_font_size(scale))
                    .draw();
            });
        }
    }

    // Handle pre-render (remove elements that leave viewport, add them back when appropriate) 
    function pre_render() {
        buffer.children("*")
            .each(function(i, el) {
                d3.transform(d3.select(el).attr("transform"));
                var el_path = $(el)[0],
                    svg_wrapper = $("svg"),
                    t = parse_transform($("svg > g")[0].getAttribute("transform")),

                    element_data = $(el_path).data("coords"),

                    element_min_x = ~~element_data.min_x,
                    element_max_x = ~~element_data.max_x,
                    element_min_y = ~~element_data.min_y,
                    element_max_y = ~~element_data.max_y,

                    svg_wrapper_width = svg_wrapper.width(),
                    svg_wrapper_height = svg_wrapper.height(),

                    s = parseFloat(t.scale),
                    x = ~~t.translate[0],
                    y = ~~t.translate[1];

                if (element_min_x * s + x <= svg_wrapper_width &&
                    element_min_y * s + y <= svg_wrapper_height &&
                    0 <= element_max_x * s + x &&
                    0 <= element_max_y * s + y) {

                    if (0 == $("#" + $(el).prop("id")).length) {

                        if (("n" == $(el).prop("id").charAt(0))) {
                            // insert nodes above edges
                            $(el).clone(1).appendTo($("svg > g"));
                            wrap_text(scale = t.scale);
                        } else {
                            // insert edges
                            $(el).clone(1).prependTo($("svg > g"));
                        }
                    }
                } else {

                    id = $(el).prop("id");
                    $("#" + id).remove();
                }
            });
    }
    d3.scale.category20();
    var link = d3.select("body")
        .append("svg")
        .attr("width", viewport_width)
        .attr("height", viewport_height)
        .attr("pointer-events", "all")
        .append("svg:g")
        .call(d3.behavior.zoom().scaleExtent(zoom_extent)),
        layout_tree = d3.layout.tree()
        .nodeSize([separation_height * 2, separation_width * 2])
        .separation(function() {
            return node_separation;
        })
        .children(function(a) {
            return a._parents;
        }),
        nodes = layout_tree.nodes(json),
        edges = layout_tree.links(nodes);

    // Style links (edges)
    link.selectAll("path.link")
        .data(edges)
        .enter()
        .append("path")
        .attr("class", "link")
        .attr("d", function(a) {
            return "M" + a.source.y + "," + a.source.x + "H" + ~~(a.source.y + (a.target.y - a.source.y) / 2) + "V" + a.target.x + "H" + a.target.y;
        });

    // Style nodes
    var node = link.selectAll("g.person")
        .data(nodes)
        .enter()
        .append("g")
        .attr("transform", function(a) {
            return "translate(" + a.y + "," + a.x + ")";
        })
        .attr("class", "person");

    // Draw the rectangle person boxes
    node.append("rect")
        .attr({
            x: -(node_width / 2),
            y: -(node_height / 2),
            width: node_width,
            height: node_height
        });

    // Draw the person name and position it inside the box
    node_text = node.append("text")
        .attr("text-anchor", "start")
        .text(function(a) {
            return a.name;
        });

    // Text wrap on all nodes using d3plus. By default there is not any left or
    // right padding. To add padding we would need to draw another rectangle,
    // inside of the rectangle with the border, that represents the area we would
    // like the text to be contained in.
    d3.selectAll("text")
        .each(function(a, b) {
            d3plus.textwrap()
                .container(d3.select(this))
                .valign("middle")
                .resize(!0)
                .size(get_font_size(1))
                .draw();
        });

    // START Create off-screen render

    // Append node edges to memory, to allow pre-rendering
    $("svg > g > path")
        .each(function(a, b) {
            el = $(b)[0];
            if (d = $(el)
                .attr("d")) {
                // Parse d parameter from rect, in the format found in the d3 tree dom: M0,0H0V0V0
                for (var g = d.match(/([MLQTCSAZVH])([^MLQTCSAZVH]*)/gi), c = g.length, h, k, f, l, m = [], e = [], n = 0; n < c; n++) {
                    command = g[n], void 0 !== command && ("M" == command.charAt(0) ? (coords = command.substring(1, command.length), m.push(~~coords.split(",")[0]), e.push(~~coords.split(",")[1])) : "V" == command.charAt(0) ? e.push(~~command.substring(1, command.length)) : "H" == command.charAt(0) && m.push(~~command.substring(1, command.length)));
                }
                0 < m.length && (h = Math.min.apply(this, m), f = Math.max.apply(this, m));
                0 < e.length && (k = Math.min.apply(this, e), l = Math.max.apply(this, e));
                $(el).data("position", a);
                $(el).prop("id", "e" + a);
                $(el).data("coords", {
                    min_x: h,
                    min_y: k,
                    max_x: f,
                    max_y: l
                });
                // Store element coords in memory
                hidden_element = $(el).clone(1);
                buffer.append(hidden_element);
            }
        });

    // Append node elements to memory
    $("svg > g > g").each(function(a, b) {
        el = $("rect", b);
        transform = b.getAttribute("transform");
        null !== transform && void 0 !== transform ? (t = parse_transform(transform), tx = ~~t.translate[0], ty = ~~t.translate[1]) : ty = tx = 0;
        // Calculate element area
        el_min_x = ~~el.attr("x");
        el_min_y = ~~el.attr("y");
        el_max_x = ~~el.attr("x") + ~~el.attr("width");
        el_max_y = ~~el.attr("y") + ~~el.attr("height");
        $(b).data("position", a);
        $(b).prop("id", "n" + a);
        $(b).data("coords", {
            min_x: el_min_x + tx,
            min_y: el_min_y + ty,
            max_x: el_max_x + tx,
            max_y: el_max_y + ty
        });
        text_el = $("text", $(b));
        0 < text_el.length && $(b).data("text", d3.select(text_el[0])[0][0].__data__.name);

        // Store element coords in memory
        hidden_element = $(b).clone(1);
        // store node in memory
        buffer.append(hidden_element);
    });

    // END Create off-screen render

    d3_svg = d3.select("svg");
    svg_group = d3.select("svg > g");

    // Setup zoom and pan
    zoom = d3.behavior.zoom()
        .on("zoom", function() {
            previous_transform = $("svg > g")[0].getAttribute("transform");
            svg_group.style("stroke-width", 1.5 / d3.event.scale + "px");
            svg_group.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")");
            pre_render();

            if (previous_transform !== null) {
                previous_transform = parse_transform(previous_transform);
                if (previous_transform.scale != d3.event.scale) {

                    // ConnorsFan solution
                    if (refresh_timeout) {
                        clearTimeout(refresh_timeout);
                    }
                    scale = d3.event.scale;
                    refresh_timeout = setTimeout(function() {
                        wrap_text(scale = scale);
                    }, refresh_delay, scale);

                }
            }
        });
    // Apply initial zoom / pan
    d3_svg.call(zoom);
});