Search code examples
javascriptd3.jstreemap

How to make sure text in treemap cell do not overflow?


http://bl.ocks.org/mundhradevang/1387786

Something similar like this. But I have lots of data so the text is a LOT. So what is the best way to make sure each individual text wraps nicely inside the treemap cell?

I use https://github.com/iros/underscore.nest underscore.nest to nest my JSON data so it looks like this

{"children":[{"name":"Afghanistan",
 "children":[{"name":"Qal eh-ye Now","value":2997},
 {"name":"Mahmud-E Eraqi","value":7407}

But when drawing my treemap, the text are all over the place, enter image description here

This is my D3 code to append the text :

 cells
.append("text")
.attr("x", function (d) {
  return d.x + d.dx / 2;
})
.attr("y", function (d) {
  return d.y + d.dy / 2;
})
.attr("text-anchor", "middle")
.text(function (d) { return d.children ? null : d.name })

Full Dataset: https://api.myjson.com/bins/g9p4s

My Full Code :

 var coordinates = { x: 0, y: 0 };
 var margin = { top: 40, right: 10, bottom: 90, left: 20 }
  var cfg = {
    margin: { top: 40, right: 10, bottom: 90, left: 140 },
    width: 960 - margin.left - margin.right,
    height: 500 - margin.top - margin.bottom,
    color: d3.scale.category20()
  };

  //Put all of the options into a variable called cfg
  if ('undefined' !== typeof options) {
    for (var i in options) {
      if ('undefined' !== typeof options[i]) { cfg[i] = options[i]; }
    }//for i
  }

  var treemap,
    legendCategories,
    uniqueCategories;
  var half = cfg.height / 2;
  var tool = d3.select("body").append("div").attr("class", "toolTip");

  /////////////////////////////////////////////////////////
  //////////// Create the container SVG and g /////////////
  /////////////////////////////////////////////////////////


  //Remove whatever chart with the same id/class was present before
  d3.select(id).select("svg").remove();

  //Initiate the radar chart SVG
  var canvas = d3
    .select(id)
    .append("svg")
    .attr("class", "chart")
    .attr("width", cfg.width + cfg.margin.left + cfg.margin.right)
    .attr("height", cfg.height + cfg.margin.top + cfg.margin.bottom)
    .attr("id", "canvas");
  var innercanvas = canvas
    .append("g")
    .attr("class", "innercanvas")
    .attr("transform", "translate(" + cfg.margin.left + "," + cfg.margin.top + ")");

  legendCategories = data.children.map(a => a.name);
  uniqueCategories = legendCategories.filter(onlyUnique);

  var categoryTitle = String(categoryKey);
  categoryTitle = categoryTitle.substring(categoryTitle.indexOf("." + 1));
  categoryScale = cfg.color;
  categoryScale.domain(uniqueCategories);
  verticalLegend = d3.svg
    .legend()
    .labelFormat("none")
    .cellPadding(5)
    .orientation("vertical")
    .units(categoryTitle)
    .cellWidth(20)
    .cellHeight(10)
    .place(coordinates)
    .inputScale(categoryScale)
    .cellStepping(10);


  treemap = d3.layout
    .treemap()
    .round(false)
    .size([cfg.width, cfg.height])
    .padding(.25)
    .sticky(true)
    .nodes(data);

  var cells = innercanvas
    .selectAll(".newcell")
    .data(treemap)
    .enter()
    .append("g")
    .attr("class", "newcell");

  cells
    .append("rect")
    .attr("x", function (d) {
      return d.x;
    })
    .attr("y", function (d) {
      return d.y;
    })
    .attr("id", "rectangle")
    .attr("width", function (d) {
      return d.dx;
    })
    .attr("height", function (d) {
      return d.dy;
    })
    .style("fill", function (d) {
      return d.children ? cfg.color(d.name) : null;
    })
    .attr("stroke", "#000000")
    .attr('pointer-events', 'all')
    .on("mousemove", function (d) {
      tool.style("left", d3.event.pageX + 10 + "px")
      tool.style("top", d3.event.pageY - 20 + "px")
      tool.style("display", "inline-block");
      tool.html(d.children ? null : d.name + "<br>" + d.value);
    }).on("mouseout", function (d) {
      tool.style("display", "none");
    });

  cells
    .append("text")
    .attr("x", function (d) {
      return d.x + d.dx / 2;
    })
    .attr("y", function (d) {
      return d.y + d.dy / 2;
    })
    .attr("text-anchor", "middle")
    .text(function (d) { return d.children ? null : d.name })



  canvas
    .append("g")
    .attr("transform", "translate(40,50)")
    .attr("class", "legend")
    .attr("id", "legend")
    .call(verticalLegend);




  function onlyUnique(value, index, self) {
    return self.indexOf(value) === index;
  }

Solution

  • There are a few ways to approach the problem of having a large dataset of strings of varying length that we need to fit into boxes of varying sizes.

    One way would be to add a clipping path (clip-path) to each rectangle element, but I think that would be over the top for a visualisation like this, so we'll use other means.

    First, you could add a title element to each g; the default action for most browsers is to show a tooltip on mousing over a title. So:

    cells
      .append('title')
      .text(function(d){ return d.name });
    

    Now let's look at the text elements. Set the font-family and font-size for the text nodes in your stylesheet or document head so that we are dealing with predictable text sizes.

    .newcell text {
      font-family: Arial, sans-serif;
      font-size: 10px;
    }
    

    I would advise shifting the text down slightly as the current code sets the baseline of the text at the vertical centre of the cell, which is too high. Here I've added an offset of 0.35em:

    selection
      .append("text")
      .attr("x", function (d) {
        return d.x + d.dx / 2;
      })
      .attr("y", function (d) {
        return d.y + d.dy / 2;
      })
      .attr('dy', '.35em')
      .attr("text-anchor", "middle")
      .text(function (d) {
        return d.children ? '' : d.name;
      })
    

    We can filter the visibility of the text nodes by altering the opacity (which means we can easily toggle the opacity to show / hide the text), using

        cells
        .style('opacity', function(d){
          // some function returning 0 or 1
        });
    

    There are various different methods we could use to decide whether to show or hide the text. One would be to have a minimum width and minimum height that the cell must be to show text in it, e.g.

      var minHeight = 12,
      minWidth = 20;
      cells
        .style('opacity', function(d){
          if ( d.dx <= minWidth || d.dy <= minHeight ) {
            return 0
          };
          return 1;
        });
    

    Another way could be to calculate the approximate width of the word and test that against the box width. I found a table of average character widths for common fonts at https://www.math.utah.edu/~beebe/fonts/afm-widths.html, so we can guess the width of the word by multiplying the word length by the average character width (in points), and multiply that by the font size (in points) and the conversion factor for converting points to pixels

      var pt_px = 0.75, // convert font size in pt into pixels
      font_size = 10,   // or whatever it is set to in the stylesheet
      averageLetterWidth = 0.58344; // average character width for Arial
    
      cells
        .style('opacity', function(d){
          if ( d.name.length * averageLetterWidth * pt_px * font_size >= d.dx ) {
            return 0
          }
          return 1;
        });
    

    And yet another method would be to compare the size of the bounding box of the text element to the height and width of the cell:

      cells
        .style('opacity', function(d){
          var bbox = this.getBBox();
          if ( d.dx <= bbox.width || d.dy <= bbox.height ) {
            return 0;
          }
          return 1;
        });
    

    You may also want to ensure there is some padding between the edge of the text and the box:

      var h_pad = 2, // 2 pixels vertical padding
      v_pad = 4; // 4 pixels of horizontal padding (2 px at each side)
      cells
        .style('opacity', function(d){
          var bbox = this.getBBox();
          if ( d.dx <= bbox.width + h_pad || d.dy <= bbox.height + v_pad ) {
            return 0;
          }
          return 1;
        });
    

    You can choose whichever of these methods is most appropriate for your use case (or combine them all!). I have put together a block that demonstrates the different calculations and their effects.