I'm using d3.js to plot a highway network over a map SVG. I'd like to be able to vary the stroke-weight of the line to illustrate demand based on a value.
Highway links are define as one way, so for example a two way road would have two overlapping line elements (with separate id's). I can use stroke-weight to edit the thickness of the line based on a variable (as below), but on a two way road, the larger of the two stroke weights will always cover the smaller rendering it invisible.
Is there an easy way to offset a line by half its stroke-weight to the left hand side of the direction the line is drawn? (direction denoted by x1,y1 x2,y2)
d3.csv("links.csv", function (error, data) {
d3.select("#lines").selectAll("line")
.data(data)
.enter()
.append("line")
.each(function (d) {
d.p1 = projection([d.lng1, d.lat1]);
d.p2 = projection([d.lng2, d.lat2]);
})
.attr("x1", function (d) { return d.p1[0]; })
.attr("y1", function (d) { return d.p1[1]; })
.attr("x2", function (d) { return d.p2[0]; })
.attr("y2", function (d) { return d.p2[1]; })
.on('mouseover', tip_link.show)
.on('mouseout', tip_link.hide)
.style("stroke", "black")
.style("stroke-width", lineweight)
});
One option would be to just create new start/end points when drawing your lines and use those:
var offset = function(start,destination,distance) {
// find angle of line
var dx = destination[0] - start[0];
var dy = destination[1] - start[1];
var angle = Math.atan2(dy,dx);
// offset them:
var newStart = [
start[0] + Math.sin(angle-Math.PI)*distance,
start[1] + Math.cos(angle)*distance
];
var newDestination = [
destination[0] + Math.sin(angle-Math.PI)*distance,
destination[1] + Math.cos(angle)*distance
];
// return the new start/end points
return [newStart,newDestination]
}
This function takes two points and offsets them by a particular amount given the angle between the two points. Negative values shift to the other side, swapping the start and destination points will shift to the other side.
In action, this looks like, with the original line in black:
var offset = function(start,destination,distance) {
// find angle of line
var dx = destination[0] - start[0];
var dy = destination[1] - start[1];
var angle = Math.atan2(dy,dx);
// offset them:
var newStart = [
start[0] + Math.sin(angle-Math.PI)*distance,
start[1] + Math.cos(angle)*distance
];
var newDestination = [
destination[0] + Math.sin(angle-Math.PI)*distance,
destination[1] + Math.cos(angle)*distance
];
// return the new start/end points
return [newStart,newDestination]
}
var line = [
[10,10],
[200,100]
];
var svg = d3.select("svg");
// To avoid repetition:
function draw(selection) {
selection.attr("x1",function(d) { return d[0][0]; })
.attr("x2",function(d) { return d[1][0]; })
.attr("y1",function(d) { return d[0][1]; })
.attr("y2",function(d) { return d[1][1]; })
}
svg.append("line")
.datum(line)
.call(draw)
.attr("stroke","black")
.attr("stroke-width",1)
svg.append("line")
.datum(offset(...line,6))
.call(draw)
.attr("stroke","orange")
.attr("stroke-width",10)
svg.append("line")
.datum(offset(...line,-4))
.call(draw)
.attr("stroke","steelblue")
.attr("stroke-width",5)
<svg width="500" height="300"></svg>
<script src="https://d3js.org/d3.v4.min.js"></script>
You will need to adapt this to your data structure, and it requires twice as many lines as before, because you aren't using stroke width, your using lines. This is advantageous if you wanted to use canvas.
I have 3 pie charts that get loaded with an animation.
Each of this chart, has a text inside, the percent number, that one chart is loaded follow the animation and start the counting from 0% to tot%
I was able to show the text in one pie chart, but when i adapt the code to be used from 3 pie charts, i cannot find the way to display 3 text.
I loop trought the 3 div and trhough the 3 percent number, i can see the correct percent number in the console, but then nothing get displayed inside the pie chart :/
I'm new to d3 so it might be much easier then what i can see.
app.numbers = {
calcPerc : function(percent) {
return [percent, 100-percent];
},
drawDonutChart : function(el, percent, width, height, text_y) {
width = typeof width !== undefined ? width : 290; //width
height = typeof height !== undefined ? height : 290; //height
text_y = typeof text_y !== undefined ? text_y : "-.10em";
var radius = Math.min(width, height) / 2;
var pie = d3.pie().sort(null);
var dataset = {
lower: this.calcPerc(0),
upper: this.calcPerc(percent)
}
//this.percents = percent;
this.arc = d3.arc()
.innerRadius(radius - 20)
.outerRadius(radius);
var svg = d3.select(el).append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
var path = svg.selectAll("path")
.data(pie(dataset.lower))
.enter().append("path")
.attr("class", function(d, i) { return "color" + i })
.attr("d", this.arc)
.each(function(d) { this._currentArc = d; }); // store the initial values
// add text in the center of the donut
this.text = svg.append('text')
.attr("text-anchor", "middle")
.attr("dy", text_y)
.attr("d", percent)
if (typeof(percent) === "string") {
this.text.text(percent);
} else {
var self = this;
var timeout = setTimeout( function () {
clearTimeout(timeout);
path = path.data(pie(dataset.upper)); // update the data
path.transition().ease(d3.easeExp).duration('500').attrTween("d", function(a){
var progress = 0;
var format = d3.format(".0%");
// Store the displayed angles in _currentArc.
// Then, interpolate from _currentArc to the new angles.
// During the transition, _currentArc is updated in-place by d3.interpolate.
var i = d3.interpolate(this._currentArc, a);
var i2 = d3.interpolate(progress, percent);
this._currentArc = i(0);
return function(t) {
$(self.text).each(function(){
$(this).text( format(i2(t) / 100) );
});
return self.arc(i(t));
};
}); // redraw the arcs
}, 200);
}
},
init : function(){
$('.donut').each( function() {
var percent = $(this).data('donut');
app.numbers.drawDonutChart(
this,
percent,
190,
190,
".35em"
);
})
}
}
}
// initialize pie on scroll
window.onscroll = function() {
app.numbers.init();
}
Html:
<section class="row numbers section-bkg section-bkg--blue">
<div class="container">
<div class="col-xs-12 col-sm-4">
<div class="donut" data-donut="42"></div>
</div>
<div class="col-xs-12 col-sm-4">
<div class="donut" data-donut="12"></div>
</div>
<div class="col-xs-12 col-sm-4">
<div class="donut" data-donut="86"></div>
</div>
</div>
</section>
Any idea how should i display the text?
i'm pretty sure the problem is here:
$(self.text).each(function(){
$(this).text( format(i2(t) / 100) );
});
Despite mixing jQuery and D3 never being a good idea (try to avoid it), it seems that that was not the problem. The problem seems to be the fact that you're changing the text inside the factory, using a previously assigned self.
One solution (without changing your code too much, certainly there are better ways to refactor it) is getting the texts based on the current <path>:
var self2 = this;//this is the transitioning path
return function(t) {
d3.select(self2.parentNode).select("text").text(format(i2(t) / 100));
return self.arc(i(t));
};
Here is your code (without jQuery):
var app = {};
var color = d3.scaleOrdinal(d3.schemeCategory10)
app.numbers = {
calcPerc: function(percent) {
return [percent, 100 - percent];
},
drawDonutChart: function(el, percent, width, height, text_y) {
width = typeof width !== undefined ? width : 290; //width
height = typeof height !== undefined ? height : 290; //height
text_y = typeof text_y !== undefined ? text_y : "-.10em";
var radius = Math.min(width, height) / 2;
var pie = d3.pie().sort(null);
var dataset = {
lower: this.calcPerc(0),
upper: this.calcPerc(percent)
}
//this.percents = percent;
this.arc = d3.arc()
.innerRadius(radius - 20)
.outerRadius(radius);
var svg = d3.select(el).append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
var path = svg.selectAll("path")
.data(pie(dataset.lower))
.enter().append("path")
.attr("fill", function(d, i) {
return color(i)
})
.attr("d", this.arc)
.each(function(d) {
this._currentArc = d;
}); // store the initial values
// add text in the center of the donut
this.text = svg.append('text')
.attr("text-anchor", "middle")
.attr("dy", text_y)
.attr("d", percent)
this.text.text(percent);
var self = this;
path = path.data(pie(dataset.upper)); // update the data
path.transition().ease(d3.easeExp).duration(1000).attrTween("d", function(a) {
var progress = 0;
var format = d3.format(".0%");
// Store the displayed angles in _currentArc.
// Then, interpolate from _currentArc to the new angles.
// During the transition, _currentArc is updated in-place by d3.interpolate.
var i = d3.interpolate(this._currentArc, a);
var i2 = d3.interpolate(progress, percent);
this._currentArc = i(0);
var self2 = this;
return function(t) {
d3.select(self2.parentNode).select("text").text(format(i2(t) / 100));
return self.arc(i(t));
};
}); // redraw the arcs
},
init: function() {
d3.selectAll('.donut').each(function() {
var percent = d3.select(this).node().dataset.donut;
app.numbers.drawDonutChart(
this,
percent,
190,
190,
".35em"
);
})
}
}
app.numbers.init();
<script src="https://d3js.org/d3.v4.min.js"></script>
<section class="row numbers section-bkg section-bkg--blue">
<div class="container">
<div class="col-xs-12 col-sm-4">
<div class="donut" data-donut="42"></div>
</div>
<div class="col-xs-12 col-sm-4">
<div class="donut" data-donut="12"></div>
</div>
<div class="col-xs-12 col-sm-4">
<div class="donut" data-donut="86"></div>
</div>
</div>
</section>
I am creating a tree chart with d3.js, it works fine... but I want text to react to zooming, Here is the JSFiddle.
Please look at first node... it has lots of characters (in my case max will be 255)
When zoomed in or out, my text remains same, but I want to see all on zoom in.
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's 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>
I made a sample of your requirement in this fiddle
It may need some more tweaking to position the text vertical middle; but this can be the base for you to work on. Calculations are done in the function wrap() and call on page load and zooming.
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);
});
}
Also note that I've added the style dominant-baseline: hanging; to .person class
The code in this jsfiddle is an attempt to address the performance issues that you have with very large tree charts. A delay is set with setTimeout in the zoom event handler to allow zooming at "full speed", without text resizing. Once the zooming stops for a short time, the text is rearranged according to the new scaling:
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);
})
The delay (in milliseconds) depends on the number of nodes in the tree. You can experiment with the mathematical expression to find the best parameters for the wide range of node counts that you expect in your tree.
// Calculate the refresh delay
refreshDelay = Math.pow(node.size(), 0.5) * 2.0;
You can also set the parameters in calcFontSize to fit your needs:
// Calculate the font size for the current scaling
var calcFontSize = function () {
return Math.min(24, 10 * Math.pow(scaleValue, -0.25))
}
The initialization of the nodes has been slightly modified:
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;
});
And the text is processed in 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);
});
}
Text wrapping can be process intensive if we have a lot of text. To address those issues, present in my first answer, this new version has improved performance, thanks to pre-rendering.
This script creates an element outside of the DOM, and stores all nodes and edges into it. Then it checks which elements would be visible, removing them from the DOM, and adding them back when appropriate.
I'm making use of jQuery for data(), and for selecting elements. In my example on the fiddle, there are 120 nodes. But it should work similarly for much more, as the only nodes rendered are the ones on screen.
I changed the zoom behaviour, so that the zoom is centered on the mouse cursor, and was surprised to see that the pan / zoom works on iOS as well.
See it in action.
UPDATE
I applied the timeout (ConnorsFan's solution), as it makes a big difference. In addition, I added a minimum scale for which text should be re-wrapped.
$(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's 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's 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's 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);
});
I have a d3.js force layout with some tweaks to the force calculations. As I'm working with large datasets, sometimes the graph is partly or entirely outside of the viewport. I'd like to add a command to rescale and center the graph to be inside the viewport, but having some trouble with that.
what works:
I have a canvas and a viewport onto it:
this.svg_canvas = d3.select("#" + this.container_id)
.append("svg")
.attr("width", this.width)
.attr("height", this.height)
.call(this.zoom_behavior.bind(this))
;
this.viewport = this.svg_canvas.append("g")
.attr("id", "viewport")
;
I have a zoom behavior that scales and translates the viewport:
this.zoom_behavior = d3.behavior.zoom()
.scaleExtent([GraphView.MIN_ZOOM, GraphView.MAX_ZOOM])
.on('zoom', this._handleZoom.bind(this))
;
GraphView.prototype._handleZoom = function() {
var translate = d3.event.translate;
var scale = d3.event.scale;
this.viewport.attr("transform",
"translate(" + translate + ") " +
"scale(" + scale + ")");
};
All of that works as it should.
what doesn't work
I added a "recenter and scale" method that is supposed to perform the scaling and translation to bring the graph onto the viewport. The way it is supposed to work is that it first finds the extent of the graph (via my boundingBox() method), then call zoom.behavior with the appropriate scaling and translation arguments:
GraphView.prototype.recenterAndScale = function(nodes) {
var bounding_box = this._boundingBox(nodes || this.force.nodes());
var viewport = this.zoom_behavior.size(); // viewport [width, height]
var tx = viewport[0]/2 - bounding_box.x0;
var ty = viewport[1]/2 - bounding_box.y0;
var scale = Math.min(viewport[0]/bounding_box.dx, viewport[1]/bounding_box.dy);
this.zoom_behavior.translate([tx, ty])
.scale(scale)
.event(this.svg_canvas)
;
};
This doesn't work. It (usually) locates the graph the edge of my viewport. Perhaps I'm using the wrong reference for something. Is there an online example of how to do this "properly" (using the d3 idioms)?
For completeness, here's my definition of boundingBox() -- it returns the geometric center and extent of the nodes in the graph. As far as I can tell, this is working properly:
// Return {dx:, dy:, x0:, y0:} for the given nodes. If no nodes
// are given, return {dx: 0, dy: 0, x0: 0, y0: 0}
GraphView.prototype._boundingBox = function(nodes) {
if (nodes.length === 0) {
return {dx: 0, dy: 0, x0: 0, y0: 0};
} else {
var min_x = Number.MAX_VALUE;
var min_y = Number.MAX_VALUE;
var max_x = -Number.MAX_VALUE;
var max_y = -Number.MAX_VALUE;
nodes.forEach(function(node) {
if (node.x < min_x) min_x = node.x;
if (node.x > max_x) max_x = node.x;
if (node.y < min_y) min_y = node.y;
if (node.y > max_y) max_y = node.y;
});
return {dx: max_x - min_x,
dy: max_y - min_y,
x0: (max_x + min_x) / 2.0,
y0: (max_y + min_y) / 2.0
};
}
}
This was actually pretty easy: I needed to apply the effects of scaling when computing the translation. The corrected recenterAndScale() method is:
GraphView.prototype.recenterAndScale = function(nodes) {
var bbox = this._boundingBox(nodes || this.force.nodes());
var viewport = this.zoom_behavior.size(); // => [width, height]
var scale = Math.min(viewport[0]/bbox.dx, viewport[1]/bbox.dy);
var tx = viewport[0]/2 - bbox.x0 * scale; // <<< account for scale
var ty = viewport[1]/2 - bbox.y0 * scale; // <<< account for scale
this.zoom_behavior.translate([tx, ty])
.scale(scale)
.event(this.svg_canvas)
;
};
I have a map which has been translated to make it fit on the canvas properly.
I'm trying to implement a way to zoom it and it does work, but it moves away from center when you zoom in, rather than centering on the mouse or even the canvas.
This is my code:
function map(data, total_views) {
var xy = d3.geo.mercator().scale(4350),
path = d3.geo.path().projection(xy),
transX = -320,
transY = 648,
init = true;
var quantize = d3.scale.quantize()
.domain([0, total_views*2/Object.keys(data).length])
.range(d3.range(15).map(function(i) { return "map-colour-" + i; }));
var map = d3.select("#map")
.append("svg:g")
.attr("id", "gb-regions")
.attr("transform","translate("+transX+","+transY+")")
.call(d3.behavior.zoom().on("zoom", redraw));
d3.json(url_prefix + "map/regions.json", function(json) {
d3.select("#regions")
.selectAll("path")
.data(json.features)
.enter().append("svg:path")
.attr("d", path)
.attr("class", function(d) { return quantize(data[d.properties.fips]); });
});
function redraw() {
var trans = d3.event.translate;
var scale = d3.event.scale;
if (init) {
trans[0] += transX;
trans[1] += transY;
init = false;
}
console.log(trans);
map.attr("transform", "translate(" + trans + ")" + " scale(" + scale + ")");
}
}
I've found that adding the initial translation to the new translation (trans) works for the first zoom, but for all subsequent zooms it makes it worse. Any ideas?
Here's a comprehensive starting-point: semantic zooming of force directed graph in d3
And this example helped me specifically (just rip out all the minimap stuff to make it simpler): http://codepen.io/billdwhite/pen/lCAdi?editors=001
var zoomHandler = function(newScale) {
if (!zoomEnabled) { return; }
if (d3.event) {
scale = d3.event.scale;
} else {
scale = newScale;
}
if (dragEnabled) {
var tbound = -height * scale,
bbound = height * scale,
lbound = -width * scale,
rbound = width * scale;
// limit translation to thresholds
translation = d3.event ? d3.event.translate : [0, 0];
translation = [
Math.max(Math.min(translation[0], rbound), lbound),
Math.max(Math.min(translation[1], bbound), tbound)
];
}
d3.select(".panCanvas, .panCanvas .bg")
.attr("transform", "translate(" + translation + ")" + " scale(" + scale + ")");
minimap.scale(scale).render();
}; // startoff zoomed in a bit to show pan/zoom rectangle
Though I had to tweak that function a fair bit to get it working for my case, but the idea is there. Here's part of mine. (E.range(min,max,value) just limits value to be within the min/max. The changes are mostly because I'm treating 0,0 as the center of the screen in this case.
// limit translation to thresholds
var offw = width/2*scale;
var offh = height/2*scale;
var sw = width*scale/2 - zoomPadding;
var sh = height*scale/2- zoomPadding;
translate = d3.event ? d3.event.translate : [0, 0];
translate = [
E.range(-sw,(width+sw), translate[0]+offw),
E.range(-sh,(height+sh), translate[1]+offh)
];
}
var ts = [translate[0], translate[1]];
var msvg = [scale, 0, 0, scale, ts[0], ts[1]];