How to accurately zoom d3 maps which have already been translated - javascript

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]];

Related

d3.js liquid chart bug from circle to rect shapes

I am building a d3.js version 4 -- liquid chart -- it has been derived from this waterchart circle gauges - but I am looking to make square bar/variants.
Bug/Problem -- fix the code so the liquid chart fills correctly as a bar percentage.
-- the difference between these two jsfiddles is just the config1.fillShape parameter. -- rect/circle
// broken bar version
http://jsfiddle.net/0ht35rpb/132/
// working old round gauge version
http://jsfiddle.net/0ht35rpb/133/
I am not sure what needs to be changed to get this working accordingly
I've been focusing on the starred aspects -- trying to test the result with config.height as a variable in the equations - but its not producing stable results -- 1% and 99% ranges.
waveHeightScale
waveHeightScaling
waveRiseScale
clipArea*
fillGroup*
waveRise
I am having issues trying to get the waveHeight/clipArea to understand the extra bar height -- when I have played with it in the past -- it fills up more space -- but then the bar height value doesn't feel right -- like 1% is too high.
here is the actual code -- it will be part of a reactjs component.
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import * as d3 from "d3";
import $ from 'jquery';
import _ from 'underscore';
import './liquidchart.css';
class LiquidChart extends Component {
componentDidMount() {
var $this = $(ReactDOM.findDOMNode(this));
console.log("chart--< this.props",this.props)
var val = $this.data("val");
var config = liquidFillGaugeDefaultSettings();
config.backgroundColor = this.props.backgroundColor;
config.textColor = this.props.textColor;
config.waveTextColor = this.props.waveTextColor;
config.waveStartColor = this.props.waveStartColor;
config.waveColorDuration = this.props.waveColorDuration;
config.waveColor = this.props.waveColor;
config.fillShape = this.props.fillShape;
config.circleThickness = this.props.circleThickness;
config.textVertPosition = this.props.textVertPosition;
config.waveAnimateTime = this.props.waveAnimateTime;
config.displayText = this.props.displayText == "true";
config.height = this.props.height;
config.width = this.props.width;
config.displayOverlay = this.props.displayOverlay == "true";
config.overlayImageSrc = this.props.overlayImageSrc;
config.overlayImageHeight = this.props.overlayImageHeight;
config.overlayImageWidth = this.props.overlayImageWidth;
config.axisLabel = this.props.axisLabel;
var gauge = loadLiquidFillGauge($this, val, config);
function liquidFillGaugeDefaultSettings(){
return {
height: 90,
width: 90,
minValue: 0, // The gauge minimum value.
maxValue: 100, // The gauge maximum value.
circleThickness: 0.05, // The outer circle thickness as a percentage of it's radius.
circleFillGap: 0.05, // The size of the gap between the outer circle and wave circle as a percentage of the outer circles radius.
backgroundColor: "#178BCA", // The color of the outer circle.
waveHeight: 0.1, // The wave height as a percentage of the radius of the wave circle.
waveCount: 2, // The number of full waves per width of the wave circle.
waveRiseTime: 2000, // The amount of time in milliseconds for the wave to rise from 0 to it's final height.
waveAnimateTime: 1500, // The amount of time in milliseconds for a full wave to enter the wave circle.
waveRise: true, // Control if the wave should rise from 0 to it's full height, or start at it's full height.
waveHeightScaling: true, // Controls wave size scaling at low and high fill percentages. When true, wave height reaches it's maximum at 50% fill, and minimum at 0% and 100% fill. This helps to prevent the wave from making the wave circle from appear totally full or empty when near it's minimum or maximum fill.
waveAnimate: true, // Controls if the wave scrolls or is static.
waveColor: "#178BCA", // The color of the fill wave.
waveOffset: .25, // The amount to initially offset the wave. 0 = no offset. 1 = offset of one full wave.
textVertPosition: .5, // The height at which to display the percentage text withing the wave circle. 0 = bottom, 1 = top.
textSize: 1, // The relative height of the text to display in the wave circle. 1 = 50%
valueCountUp: true, // If true, the displayed value counts up from 0 to it's final value upon loading. If false, the final value is displayed.
displayPercent: true, // If true, a % symbol is displayed after the value.
textColor: "#045681", // The color of the value text when the wave does not overlap it.
waveTextColor: "#A4DBf8", // The color of the value text when the wave overlaps it.
fillShape: "rect", // circle or rect - shape of wave
waveStartColor: "gold", // starting color
waveColorDuration : 1000, // how long it takes to change from starting color to wave color
displayText: true, // display the percentage text
displayOverlay: false, // display the overlay
overlayImageSrc: "", // overlay image source
overlayImageHeight: "", // overlay image height
overlayImageWidth: "", // overlay image width
axisLabel: "" //display a label at the bottom axis
};
}
function loadLiquidFillGauge(elementId, value, config) {
if(config == null) config = liquidFillGaugeDefaultSettings();
const chart = d3.select(elementId[0])
.append("svg")
.attr("width", config.width)
.attr("height", config.height);
const gauge = chart
.append("g")
.attr('transform','translate(0,0)');
if(config.displayOverlay){
const imgs = chart
.append("g")
.attr('transform','translate(0,0)')
.append("svg:image")
.attr("xlink:href", config.overlayImageSrc)
.attr("x", "0")
.attr("y", "0")
.attr("width", config.overlayImageWidth)
.attr("height", config.overlayImageHeight);
}
if(config.axisLabel){
const axisLabel = chart
.append("g")
.append("text")
.attr("x", config.width/2)
.attr("y", config.height)
.attr("dy", "-4px")
.style("text-anchor", "middle")
.text(config.axisLabel);
}
const randId = _.uniqueId('liquid_');
const radius = Math.min(parseInt(config.width), parseInt(config.height))/2;
const locationX = parseInt(config.width)/2 - radius;
var locationY = parseInt(config.height)/2 - radius;
if(config.fillShape == "rect"){
locationY = 0;
}
const fillPercent = Math.max(config.minValue, Math.min(config.maxValue, value))/config.maxValue;
let waveHeightScale = null;
if(config.waveHeightScaling){
waveHeightScale = d3.scaleLinear()
.range([0,config.waveHeight,0])
.domain([0,50,100]);
} else {
waveHeightScale = d3.scaleLinear()
.range([config.waveHeight,config.waveHeight])
.domain([0,100]);
}
const textPixels = (config.textSize*radius/2);
const textFinalValue = parseFloat(value).toFixed(2);
const textStartValue = config.valueCountUp?config.minValue:textFinalValue;
const percentText = config.displayPercent?"%":"";
const circleThickness = config.circleThickness * radius;
const circleFillGap = config.circleFillGap * radius;
const fillCircleMargin = circleThickness + circleFillGap;
const fillCircleRadius = radius - fillCircleMargin;
const waveHeight = fillCircleRadius*waveHeightScale(fillPercent*100);
const waveLength = fillCircleRadius*2/config.waveCount;
const waveClipCount = 1+config.waveCount;
const waveClipWidth = waveLength*waveClipCount;
// Rounding functions so that the correct number of decimal places is always displayed as the value counts up.
let textRounder = function(value){ return Math.round(value); };
if(parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))){
textRounder = function(value){ return parseFloat(value).toFixed(1); };
}
if(parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))){
textRounder = function(value){ return parseFloat(value).toFixed(2); };
}
// Data for building the clip wave area.
const data = [];
for(let i = 0; i <= 40*waveClipCount; i++){
data.push({x: i/(40*waveClipCount), y: (i/(40))});
}
// Scales for drawing the outer circle.
const gaugeCircleX = d3.scaleLinear().range([0,2*Math.PI]).domain([0,1]);
const gaugeCircleY = d3.scaleLinear().range([0,radius]).domain([0,radius]);
// Scales for controlling the size of the clipping path.
const waveScaleX = d3.scaleLinear().range([0,waveClipWidth]).domain([0,1]);
const waveScaleY = d3.scaleLinear().range([0,waveHeight]).domain([0,1]);
// Scales for controlling the position of the clipping path.
// The clipping area size is the height of the fill circle + the wave height, so we position the clip wave
// such that the it will overlap the fill circle at all when at 0%, and will totally cover the fill
// circle at 100%.
const waveRiseScale = d3.scaleLinear()
.range([(fillCircleMargin+fillCircleRadius*2+waveHeight),(fillCircleMargin-waveHeight)])
.domain([0,1]);
const waveAnimateScale = d3.scaleLinear()
.range([0, waveClipWidth-fillCircleRadius*2]) // Push the clip area one full wave then snap back.
.domain([0,1]);
// Scale for controlling the position of the text within the gauge.
const textRiseScaleY = d3.scaleLinear()
.range([fillCircleMargin+fillCircleRadius*2,(fillCircleMargin+textPixels*0.7)])
.domain([0,1]);
// Center the gauge within the parent SVG.
const gaugeGroup = gauge.append("g")
.attr('transform','translate('+locationX+','+locationY+')');
var drawOuterShell = function(){
// Draw the outer circle.
const gaugeCircleArc = d3.arc()
.startAngle(gaugeCircleX(0))
.endAngle(gaugeCircleX(1))
.outerRadius(gaugeCircleY(radius))
.innerRadius(gaugeCircleY(radius-circleThickness));
gaugeGroup.append("path")
.attr("d", gaugeCircleArc)
.style("fill", config.backgroundColor)
.attr('transform','translate('+radius+','+radius+')');
}
var drawOuterBlock = function(){
// Draw the outer block.
gaugeGroup.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", config.width)
.attr("height", config.height)
.style("fill", config.backgroundColor);
}
if(config.fillShape == "circle"){
drawOuterShell();
} else {
drawOuterBlock();
}
var appendText = function(relativeGroup, textColor){
// Text where the wave does not overlap.
const text = relativeGroup.append("text")
.text(textRounder(textStartValue) + percentText)
.attr("class", "liquidFillGaugeText")
.attr("text-anchor", "middle")
.attr("font-size", textPixels + "px")
.style("fill", textColor)
.attr('transform','translate('+radius+','+textRiseScaleY(config.textVertPosition)+')');
let textInterpolatorValue = textStartValue;
// Make the value count up.
if(config.valueCountUp){
text.transition()
.duration(config.waveRiseTime)
.tween("text", function() {
const i = d3.interpolateNumber(textInterpolatorValue, textFinalValue);
return (t) => {
textInterpolatorValue = textRounder(i(t));
// Set the gauge's text with the new value and append the % sign
// to the end
text.text(textInterpolatorValue + percentText);
}
});
}
}
if(config.displayText){
appendText(gaugeGroup, config.textColor);
}
// The clipping wave area.
const clipArea = d3.area()
.x(function(d) { return waveScaleX(d.x); } )
.y0(function(d) { return waveScaleY(Math.sin(Math.PI*2*config.waveOffset*-1 + Math.PI*2*(1-config.waveCount) + d.y*2*Math.PI));} );
if(config.fillShape == "circle") {
clipArea
.y1(function(d) { return (fillCircleRadius * 2 + waveHeight); } );
} else {
clipArea
.y1(function(d) { return (fillCircleRadius * 2 + waveHeight); } );
//.y1(function(d) { return (config.height - (fillCircleRadius * 2) + waveHeight); } );
}
const waveGroup = gaugeGroup.append("defs")
.append("clipPath")
.attr("id", "clipWave" + randId);
const wave = waveGroup.append("path")
.datum(data)
.attr("d", clipArea)
.attr("T", 0);
// The inner circle with the clipping wave attached.
const fillGroup = gaugeGroup.append("g")
.attr("clip-path", "url(#clipWave" + randId + ")");
var drawShapeWave = function(shape){
// Draw the wave shape.
if(shape == "circle") {
fillGroup.append("circle")
.attr("cx", radius)
.attr("cy", radius)
.attr("r", fillCircleRadius);
}else {
fillGroup.append("rect")
.attr("x", radius - fillCircleRadius)
.attr("y", radius - fillCircleRadius)
.attr("width", fillCircleRadius * 2)
.attr("height", fillCircleRadius * 2)
//.attr("height", config.height - (fillCircleRadius * 2));
}
fillGroup
.style("fill", config.waveStartColor)
.transition()
.duration(config.waveColorDuration)
.style("fill", config.waveColor);
}
drawShapeWave(config.fillShape);
if(config.displayText){
appendText(fillGroup, config.waveTextColor);
}
// Make the wave rise. wave and waveGroup are separate so that horizontal and vertical movement can be controlled independently.
const waveGroupXPosition = fillCircleMargin+fillCircleRadius*2-waveClipWidth;
if(config.waveRise){
waveGroup.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(0)+')')
.transition()
.duration(config.waveRiseTime)
.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(fillPercent)+')')
.on("start", function(){ wave.attr('transform','translate(1,0)'); }); // This transform is necessary to get the clip wave positioned correctly when waveRise=true and waveAnimate=false. The wave will not position correctly without this, but it's not clear why this is actually necessary.
} else {
waveGroup.attr('transform','translate('+waveGroupXPosition+','+waveRiseScale(fillPercent)+')');
}
if(config.waveAnimate) animateWave();
function animateWave() {
wave.attr('transform','translate('+waveAnimateScale(wave.attr('T'))+',0)');
wave.transition()
.duration(config.waveAnimateTime * (1-wave.attr('T')))
.ease(d3.easeLinear)
.attr('transform','translate('+waveAnimateScale(1)+',0)')
.attr('T', 1)
.on('end', function(){
wave.attr('T', 0);
animateWave(config.waveAnimateTime);
});
}
}
}
render() {
return (
<div
className="thermometer"
data-role="thermometer"
data-backgroundColor = {this.props.backgroundColor}
data-textColor = {this.props.textColor}
data-waveTextColor = {this.props.waveTextColor}
data-waveStartColor = {this.props.waveStartColor}
data-waveColorDuration = {this.props.waveColorDuration}
data-waveColor = {this.props.waveColor}
data-fillShape = {this.props.fillShape}
data-circleThickness = {this.props.circleThickness}
data-textVertPosition = {this.props.textVertPosition}
data-waveAnimateTime = {this.props.waveAnimateTime}
data-displayText = {this.props.displayText}
data-height = {this.props.height}
data-width = {this.props.width}
data-displayOverlay = {this.props.displayOverlay}
data-overlayImageSrc = {this.props.overlayImageSrc}
data-overlayImageHeight = {this.props.overlayImageHeight}
data-overlayImageWidth = {this.props.overlayImageWidth}
data-val = {this.props.val}
data-axisLabel = {this.props.axisLabel}
>
</div>
);
}
};
export default LiquidChart;
So instead of this.
w- 70
h- 200
it looks like this

How to show full text when zoom in & truncate it when zoom out

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);
});

d3.js scaling and translating a force layout to the center of a viewport

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)
;
};

Pan/Zoom to specific group ID/getBBox() with d3.behavior.zoom

I'm attempting to make an interactive pan/zoom SVG floorplan/map using the d3.behavior.zoom() functionality. I have based my code loosely on Zoom to Bounding Box II.
I am asynchronously loading a svg via $.get() then using a button.on('click') handler to get the .getBBox() of a specific <g> by element ID, #g-1101 (represented as a red circle on the svg). I then would like to center the viewport of the svg to the middle of #g-1101's bounding box.
As a cursory try I was just trying to translate the top-level svg > g by using g#1101's .getBBox().x && .getBBox().y. It seems to me my math is off.
I've tried incorporating the (svg.node().getBBox().width / 2) - scale * g.getBBox().x) to center the middle point of the bounding box to viewport but it's translation is not even in the ballpark.
Code
(function(){
var svg, g; $.get('http://layersofcomplexity.com/__fp.svg', function(svg){
$('body').append($(svg));
init();
},'text');
function init() {
console.log('init');
svg = d3.select('svg');
g = d3.select('svg > g');
var zoom = d3.behavior.zoom()
.translate([0, 0])
.scale(1)
.scaleExtent([1, 8])
.on("zoom", zoomed);
svg
.call(zoom)
.call(zoom.event);
$('.pan').on('click', function(){
// var id = '#g-1011';
var scale = 4;
var bbox = $('#g-1101')[0].getBBox();
// d3.select(id).node().getBBox();
var x = bbox.x;
var y = bbox.y;
// var scale = .9 / Math.max(dx / width, dy / height),
// var translate = [width / 2 - scale * x, height / 2 - scale * y];
var width = svg.node().getBBox().width;
var height = svg.node().getBBox().height;
var translate = [-scale*x,-scale*y];
g.transition().duration(750) .call(zoom.translate(translate).scale(scale).event);
});
}
function zoomed() {
g.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")");
}
})();
-- EDIT JSBin was broken --
What am I missing? JSBin.
One small change in your code to center the marked in red g#g-1101
var bbox = $('#g-1101')[0].getBBox();
var x = bbox.x-((bbox.width));
var y = bbox.y-((bbox.height));
//scale should be 1 less
var translate = [-(x*(scale-1)),-(y*(scale-1))];
working code here
Hope this helps

Preventing d3 translation value in zoom event moving beyond bounds

I have a zoom event handler on my tree graph like so:
d3.select("#"+canvasId+" svg")
.call(d3.behavior.zoom()
.scaleExtent([0.05, 5])
.on("zoom", zoom));
Which calls the zoom function which handles the translation bounding logic:
function zoom() {
console.log(d3.event.translate[0]);
var wcanvas = $("#"+canvasId+" svg").width();
var hcanvas = $("#"+canvasId+" svg").height();
var displayedWidth = w*scale;
var scale = d3.event.scale;
var h = d3.select("#"+canvasId+" svg g").node().getBBox().height*scale;
var w = d3.select("#"+canvasId+" svg g").node().getBBox().width*scale;
var padding = 100;
var translation = d3.event.translate;
var tbound = -(h-hcanvas)-padding;
var bbound = padding;
var lbound = -(w-wcanvas)-padding;
var rbound = padding;
// limit translation to thresholds
translation = [
Math.max(Math.min(translation[0], rbound), lbound),
Math.max(Math.min(translation[1], bbound), tbound)
];
console.log("Width: "+w*scale+" || Height: "+h*scale+" /// "+"Left: "+translation[0]+" || Top: "+translation[1]);
d3.select("#"+canvasId+" svg g")
.attr("transform", "translate(" + translation + ")" +" scale(" + scale + ")");
console.log(d3.select("#"+canvasId+" svg g")[0]);
}
However, translations beyond the bounds cause the d3.event.translate values to increase. The result is that even if the translation is not causing the graph to move as it has reached its limit for translation, the value for the translation within successive events can continue to increase.
The result is that say I drag the graph far to the left, even though it will stop moving past a certain point, because the value within the events continues to increase, I would then have to drag it a long way back right before it actually begins to move right again.
Is there a good way to prevent this behaviour?
Okay I worked it out. The trick is to set the translation for the d3.behaviour.zoom so that successive zoom pans start at the bounded translation rather than with the additional panning that didn't actually give any movement.
To do this, we declare the zoom behaviour as a separate variable and add it to our zoomable element:
var zoomBehaviour = d3.behavior.zoom()
.scaleExtent([0.05, 5])
.on("zoom", zoom)
d3.select("#"+canvasId+" svg")
.call(zoomBehaviour);
Then we set the translation of this zoomBehaviour to our bounded translation in the zoom function:
function zoom() {
...
translation = [
Math.max(Math.min(translation[0], rbound), lbound),
Math.max(Math.min(translation[1], bbound), tbound)
];
zoomBehaviour.translate(translation);
d3.select("#"+canvasId+" svg g")
.attr("transform", "translate(" + translation + ")" +" scale(" + scale + ")");
}

Categories