textwrap.js

/**

  • Created by knut on 2015-07-21. / /

D3 Text Wrap By Vijith Assar http://www.vijithassar.com http://www.github.com/vijithassar @vijithassar

Detailed instructions at http://www.github.com/vijithassar/d3textwrap

*/

(function() {

// set this variable to a string value to always force a particular // wrap method for development purposes, for example to check tspan // rendering using a foreignobject-enabled browser. set to 'tspan' to // use tspans and 'foreignobject' to use foreignobject var force_wrap_method = false; // by default no wrap method is forced force_wrap_method = 'tspans'; // uncomment this statement to force tspans // force_wrap_method = 'foreignobjects'; // uncomment this statement to force foreignobjects

// exit immediately if something in this location // has already been defined; the plugin will defer to whatever // else you're doing in your code if(d3.selection.prototype.textwrap) { return false; }

// double check the force_wrap_method flag // and reset if someone screwed up the above // settings if(typeof force_wrap_method == 'undefined') { var force_wrap_method = false; }

// create the plugin method twice, both for regular use // and again for use inside the enter() selection d3.selection.prototype.textwrap = d3.selection.enter.prototype.textwrap = function(bounds, padding) {

   // default value of padding is zero if it's undefined
   var padding = parseInt(padding) || 0;

   // save callee into a variable so we can continue to refer to it
   // as the function scope changes
   var selection = this;

   // create a variable to store desired return values in
   var return_value;

   // extract wrap boundaries from any d3-selected rect and return them
   // in a format that matches the simpler object argument option
   var extract_bounds = function(bounds) {
       // discard the nested array wrappers added by d3
       var bounding_rect = bounds[0][0];
       // sanitize the svg element name so we can test against it
       var element_type = bounding_rect.tagName.toString();
       // if it's not a rect, exit
       if(element_type !== 'rect') {
           return false;
           // if it's a rect, proceed to extracting the position attributes
       } else {
           var bounds_extracted = {};
           bounds_extracted.x = d3.select(bounding_rect).attr('x') || 0;
           bounds_extracted.y = d3.select(bounding_rect).attr('y') || 0;
           bounds_extracted.width = d3.select(bounding_rect).attr('width') || 0;
           bounds_extracted.height = d3.select(bounding_rect).attr('height') || 0;
           // also pass along the getter function
           bounds_extracted.attr = bounds.attr;
       }
       return bounds_extracted;
   }

   // double check the input argument for the wrapping
   // boundaries to make sure it actually contains all
   // the information we'll need in order to wrap successfully
   var verify_bounds = function(bounds) {
       // quickly add a simple getter method so you can use either
       // bounds.x or bounds.attr('x') as your notation,
       // the latter being a common convention among D3
       // developers
       if(!bounds.attr) {
           bounds.attr = function(property) {
               if(this[property]) {
                   return this[property];
               }
           }
       }
       // if it's an associative array, make sure it has all the
       // necessary properties represented directly
       if(
           (typeof bounds == 'object') 
           (typeof bounds.x !== 'undefined') 
           (typeof bounds.y !== 'undefined') 
           (typeof bounds.width !== 'undefined') 
           (typeof bounds.height !== 'undefined')
       // if that's the case, then the bounds are fine
       ) {
           // return the lightly modified bounds
           return bounds;
           // if it's a numerically indexed array, assume it's a
           // d3-selected rect and try to extract the positions
       } else if (
           // first try to make sure it's an array using Array.isArray
       (
       (typeof Array.isArray == 'function') 
       (Array.isArray(bounds))
       ) ||
           // but since Array.isArray isn't always supported, fall
           // back to casting to the object to string when it's not
       (Object.prototype.toString.call(bounds) === '[object Array]')
       ) {
           // once you're sure it's an array, extract the boundaries
           // from the rect
           var extracted_bounds = extract_bounds(bounds);
           return extracted_bounds;
       } else {
           // but if the bounds are neither an object nor a numerical
           // array, then the bounds argument is invalid and you'll
           // need to fix it
           return false;
       }
   }

   var apply_padding = function(bounds, padding) {
       var padded_bounds = bounds;
       if(padding !== 0) {
           padded_bounds.x = parseInt(padded_bounds.x) + padding;
           padded_bounds.y = parseInt(padded_bounds.y) + padding;
           padded_bounds.width -= padding * 2;
           padded_bounds.height -= padding * 2;
       }
       return padded_bounds;
   }

   // verify bounds
   var verified_bounds = verify_bounds(bounds);

   // modify bounds if a padding value is provided
   if(padding) {
       verified_bounds = apply_padding(verified_bounds, padding);
   }

   // check that we have the necessary conditions for this function to operate properly
   if(
       // selection it's operating on cannot be not empty
   (selection.length == 0) ||
       // d3 must be available
   (!d3) ||
       // desired wrapping bounds must be provided as an input argument
   (!bounds) ||
       // input bounds must validate
   (!verified_bounds)
   ) {
       // try to return the calling selection if possible
       // so as not to interfere with methods downstream in the
       // chain
       if(selection) {
           return selection;
           // if all else fails, just return false. if you hit this point then you're
           // almost certainly trying to call the textwrap() method on something that
           // doesn't make sense!
       } else {
           return false;
       }
       // if we've validated everything then we can finally proceed
       // to the meat of this operation
   } else {

       // reassign the verified bounds as the set we want
       // to work with from here on; this ensures that we're
       // using the same data structure for our bounds regardless
       // of whether the input argument was a simple object or
       // a d3 selection
       bounds = verified_bounds;

       // wrap using html and foreignObjects if they are supported
       var wrap_with_foreignobjects = function(item) {
           // establish variables to quickly reference target nodes later
           var parent = d3.select(item[0].parentNode);
           var text_node = parent.select('text');
           var styled_line_height = text_node.style('line-height');
           // extract our desired content from the single text element
           var text_to_wrap = text_node.text();
           // remove the text node and replace with a foreign object
           text_node.remove();
           var foreign_object = parent.append('foreignObject');
           // add foreign object and set dimensions, position, etc
           foreign_object
               .attr(requiredFeatures, http://www.w3.org/TR/SVG11/feature#Extensibility)
               .attr('x', bounds.x)
               .attr('y', bounds.y)
               .attr('width', bounds.width)
               .attr('height', bounds.height);
           // insert an HTML div
           var wrap_div = foreign_object
               .append('xhtml:div')
               // this class is currently hardcoded
               // probably not necessary but easy to
               // override using .classed() and for now
               // it's nice to avoid a litany of input
               // arguments
               .attr('class', 'wrapped');
           // set div to same dimensions as foreign object
           wrap_div
               .style('height', bounds.height)
               .style('width', bounds.width)
               // insert text content
               .html(text_to_wrap);
           if(styled_line_height) {
               wrap_div.style('line-height', styled_line_height);
           }
           return_value = parent.select('foreignObject');
       }

       // wrap with tspans if foreignObject is undefined
       var wrap_with_tspans = function(item) {
           // operate on the first text item in the selection
           var text_node = item[0];
           var parent = text_node.parentNode;
           var text_node_selected = d3.select(text_node);
           // measure initial size of the text node as rendered
           var text_node_height = text_node.getBBox().height;
           var text_node_width = text_node.getBBox().width;
           // figure out the line height, either from rendered height
           // of the font or attached styling
           var line_height;
           var rendered_line_height = text_node_height;
           var styled_line_height = text_node_selected.style('line-height');
           if(
               (styled_line_height) 
               (parseInt(styled_line_height))
           ) {
               line_height = parseInt(styled_line_height.replace('px', ''));
           } else {
               line_height = rendered_line_height;
           }
           // only fire the rest of this if the text content
           // overflows the desired dimensions
           if(text_node_width  bounds.width) {
               // store whatever is inside the text node
               // in a variable and then zero out the
               // initial content; we'll reinsert in a moment
               // using tspan elements.
               var text_to_wrap = text_node_selected.text();
               text_node_selected.text('');
               if(text_to_wrap) {
                   // keep track of whether we are splitting by spaces
                   // so we know whether to reinsert those spaces later
                   var break_delimiter;
                   // split at spaces to create an array of individual words
                   var text_to_wrap_array;
                   if(text_to_wrap.indexOf(' ') !== -1) {
                       var break_delimiter = ' ';
                       text_to_wrap_array = text_to_wrap.split(' ');
                   } else {
                       // if there are no spaces, figure out the split
                       // points by comparing rendered text width against
                       // bounds and translating that into character position
                       // cuts
                       break_delimiter = '';
                       var string_length = text_to_wrap.length;
                       var number_of_substrings = Math.ceil(text_node_width / bounds.width);
                       var splice_interval = Math.floor(string_length / number_of_substrings);
                       if(
                           !(splice_interval * number_of_substrings = string_length)
                       ) {
                           number_of_substrings++;
                       }
                       var text_to_wrap_array = [];
                       var substring;
                       var start_position;
                       for(var i = 0; i  number_of_substrings; i++) {
                           start_position = i * splice_interval;
                           substring = text_to_wrap.substr(start_position, splice_interval);
                           text_to_wrap_array.push(substring);
                       }
                   }

                   // new array where we'll store the words re-assembled into
                   // substrings that have been tested against the desired
                   // maximum wrapping width
                   var substrings = [];
                   // computed text length is arguably incorrectly reported for
                   // all tspans after the first one, in that they will include
                   // the width of previous separate tspans. to compensate we need
                   // to manually track the computed text length of all those
                   // previous tspans and substrings, and then use that to offset
                   // the miscalculation. this then gives us the actual correct
                   // position we want to use in rendering the text in the SVG.
                   var total_offset = 0;
                   // object for storing the results of text length computations later
                   var temp = {};
                   // loop through the words and test the computed text length
                   // of the string against the maximum desired wrapping width
                   for(var i = 0; i  text_to_wrap_array.length; i++) {
                       var word = text_to_wrap_array[i];
                       var previous_string = text_node_selected.text();
                       var previous_width = text_node.getComputedTextLength();
                       // initialize the current word as the first word
                       // or append to the previous string if one exists
                       var new_string;
                       if(previous_string) {
                           new_string = previous_string + break_delimiter + word;
                       } else {
                           new_string = word;
                       }
                       // add the newest substring back to the text node and
                       // measure the length
                       text_node_selected.text(new_string);
                       var new_width = text_node.getComputedTextLength();
                       // adjust the length by the offset we've tracked
                       // due to the misreported length discussed above
                       var test_width = new_width - total_offset;
                       // if our latest version of the string is too
                       // big for the bounds, use the previous
                       // version of the string (without the newest word
                       // added) and use the latest word to restart the
                       // process with a new tspan
                       if(new_width  bounds.width) {
                           if(
                               (previous_string) 
                               (previous_string !== '')
                           ) {
                               total_offset = total_offset + previous_width;
                               temp = {string: previous_string, width: previous_width, offset: total_offset};
                               substrings.push(temp);
                               text_node_selected.text('');
                               text_node_selected.text(word);
                           }
                       }
                       // if we're up to the last word in the array,
                       // get the computed length as is without
                       // appending anything further to it
                       else if(i == text_to_wrap_array.length - 1) {
                           text_node_selected.text('');
                           var final_string = new_string;
                           if(
                               (final_string) 
                               (final_string !== '')
                           ) {
                               if((new_width - total_offset)  0) {new_width = new_width - total_offset}
                               temp = {string: final_string, width: new_width, offset: total_offset};
                               substrings.push(temp);
                           }
                       }
                   }

                   // position the overall text node
                   text_node_selected.attr('y', function() {
                       var y_offset = bounds.y;
                       // shift by line-height to move the baseline into
                       // the bounds – otherwise the text baseline would be
                       // at the top of the bounds
                       if(line_height) {y_offset += line_height;}
                       return y_offset;
                   });
                   // shift to the right by the padding value
                   if(padding) {
                       text_node_selected
                           .attr('x', bounds.x)
                       ;
                   }

                   // append each substring as a tspan
                   var current_tspan;
                   var tspan_count;
                   // double check that the text content has been removed
                   // before we start appending tspans
                   text_node_selected.text('');
                   for(var i = 0; i  substrings.length; i++) {
                       var substring = substrings[i].string;
                       if(i  0) {
                           var previous_substring = substrings[i - 1];
                       }
                       // only append if we're sure it won't make the tspans
                       // overflow the bounds.
                       if((i) * line_height  bounds.height - (line_height * 1.5)) {
                           current_tspan = text_node_selected.append('tspan')
                               .text(substring);
                           // vertical shift to all tspans after the first one
                           current_tspan
                               .attr('dy', function(d) {
                                   if(i  0) {
                                       return line_height;
                                   }
                               });
                           // shift left from default position, which
                           // is probably based on the full length of the
                           // text string until we make this adjustment
                           current_tspan
                               // .attr('dx', function() {
                               //     if(i == 0) {
                               //         var render_offset = 0;
                               //     } else if(i  0) {
                               //         render_offset = substrings[i - 1].width;
                               //         render_offset = render_offset * -1;
                               //     }
                               //     return render_offset;
                               // })
                               .attr('x', function() {

                                   return bounds.x;
                               });
                       }
                   }
               }
           }
           // assign our modified text node with tspans
           // to the return value
           return_value = d3.select(parent).selectAll('text');
       }

       // variable used to hold the functions that let us
       // switch between the wrap methods
       var wrap_method;

       // if a wrap method if being forced, assign that
       // function
       if(force_wrap_method) {
           if(force_wrap_method == 'foreignobjects') {
               wrap_method = wrap_with_foreignobjects;
           } else if (force_wrap_method == 'tspans') {
               wrap_method = wrap_with_tspans;
           }
       }

       // if no wrap method is being forced, then instead
       // test for browser support of foreignobject and
       // use whichever wrap method makes sense accordingly
       if(!force_wrap_method) {
           if(typeof SVGForeignObjectElement !== 'undefined') {
               wrap_method = wrap_with_foreignobjects;
           } else {
               wrap_method = wrap_with_tspans;
           }
       }

       // run the desired wrap function for each item
       // in the d3 selection that called .textwrap()
       for(var i = 0; i  selection.length; i++) {
           var item = selection[i];
           wrap_method(item);
       }

       // return the modified nodes so we can chain other
       // methods to them.
       return return_value;

   }

}

})(); / jshint ignore:end /