Scale and Center D3-Graphviz Graph - d3.js

What is the best way to scale and center a graph using d3-graphviz? I was hopeful that I could use scale(0.5) but this leaves the resulting graph uncentered.
I could probably go in with an .attributer() and manually adjust the <svg> and <g> elements to get what I'm looking for, but I figured there was probably a better way?
d3.select("#graph")
.graphviz()
.width(300)
.height(300)
.fit(true)
.scale(.5)
.renderDot('digraph {a -> b}');
<script src="//d3js.org/d3.v5.min.js"></script>
<script src="https://unpkg.com/#hpcc-js/wasm#0.3.11/dist/index.min.js"></script>
<script src="https://unpkg.com/d3-graphviz#3.0.5/build/d3-graphviz.js"></script>
<div id="graph" style="width: 300px; height: 300px; border: 1px solid black"></div>

There's no simple built-in way, but you can achieve almost anything with the attributer like so:
const px2pt = 3 / 4;
function attributer(datum, index, nodes) {
var selection = d3.select(this);
if (datum.tag == "svg") {
var width = datum.attributes.width;
var height = datum.attributes.height;
w = datum.attributes.viewBox.split(" ")[2];
h = datum.attributes.viewBox.split(" ")[3];
var x = (width * px2pt - w / 2) / 2;
var y = (height * px2pt - h / 2) / 2;
selection
.attr("width", width)
.attr("height", height)
.attr("viewBox", -x + " " + -y + " " + (width * px2pt) + " " + (height * px2pt));
datum.attributes.width = width;
datum.attributes.height = height;
datum.attributes.viewBox = -x + " " + -y + " " + (width * px2pt) + " " + (height * px2pt);
}
}
d3.select("#graph").graphviz()
.width(300)
.height(300)
.fit(true)
.scale(.5)
.attributer(attributer)
.renderDot('digraph {a -> b}');
<script src="//d3js.org/d3.v5.min.js"></script>
<script src="https://unpkg.com/#hpcc-js/wasm#0.3.11/dist/index.min.js"></script>
<script src="https://unpkg.com/d3-graphviz#3.0.5/build/d3-graphviz.js"></script>
<div id="graph" style="width: 300px; height: 300px; border: 1px solid black"></div>

Based on magjac's comment, I skipped .fit(), .scale(), .width(), and .height() and did it all in the attributer. This allows the solution to work even for larger graphs.
A few things to note:
Setting the height and width of the <svg> to 100% allows us to skip .width() and .height() and have the <svg> fill its container div.
Introduced a scale variable that can be set (0-1) to determine the scale of the graph
Added comments to help with anyone who finds their way here
Thank you magjac for this awesome library!
const scale = 0.8;
function attributer(datum, index, nodes) {
var selection = d3.select(this);
if (datum.tag == "svg") {
datum.attributes = {
...datum.attributes,
width: '100%',
height: '100%',
};
// svg is constructed by hpcc-js/wasm, which uses pt instead of px, so need to convert
const px2pt = 3 / 4;
// get graph dimensions in px. These can be grabbed from the viewBox of the svg
// that hpcc-js/wasm generates
const graphWidth = datum.attributes.viewBox.split(' ')[2] / px2pt;
const graphHeight = datum.attributes.viewBox.split(' ')[3] / px2pt;
// new viewBox width and height
const w = graphWidth / scale;
const h = graphHeight / scale;
// new viewBox origin to keep the graph centered
const x = -(w - graphWidth) / 2;
const y = -(h - graphHeight) / 2;
const viewBox = `${x * px2pt} ${y * px2pt} ${w * px2pt} ${h * px2pt}`;
selection.attr('viewBox', viewBox);
datum.attributes.viewBox = viewBox;
}
}
d3.select("#graph").graphviz()
.attributer(attributer)
.renderDot('digraph {a -> b -> c ->d -> e}');
<script src="//d3js.org/d3.v5.min.js"></script>
<script src="https://unpkg.com/#hpcc-js/wasm#0.3.11/dist/index.min.js"></script>
<script src="https://unpkg.com/d3-graphviz#3.0.5/build/d3-graphviz.js"></script>
<div id="graph" style="width: 300px; height: 300px; border: 1px solid black"></div>

We use the zoom behavior to do this.
Basically, what we do is that we wait for the graph to change (.on('transitionEnd')) and then recenter the graph and set the zoom level to 1.
For this, we grab our graph's SVG element and read its viewBox property.
This property is close to the size of our graph.
Then, we get the zoom behavior of our graph (the same that is used for zooming and panning with the mouse) and use its transform function.
The transform function can be used to set the zoom level as well as the translation, i.e., the panning of our graph, by passing a transform object.
d3.zoomIdentity gives us a new transform object with scale = 1, x = 0, y = 0, and by calling translate on it we can specify an x and y value to translate to. By default, d3-graphviz has on a plain graph, according to my small-scale empirical study, an x-translate of 4 and a y-translate that corresponds to the viewbox height - 4.
This leads to the following code (which also uses a transition to make the zoom transform smooth):
// you can also use 'renderEnd' if you do not use animations
graphVisualization.on('transitionEnd', () => {
// Some zoom examples: https://observablehq.com/#d3/programmatic-zoom
const svg = d3.select('#graph-wrapper svg')
const viewBox = svg.attr('viewBox').split(' ')
// const graphWidth = +viewBox[2]
const graphHeight = +viewBox[3]
const transform = graphVisualization.zoomBehavior()?.transform
// Define scale and translate
// Resetting zoom to 1
// +4 and -4 are used since they seem to be the default d3-graphviz offsets
svg.transition('translateTransition').call(transform, d3.zoomIdentity.translate(4, graphHeight - 4))
})

Related

D3.js - Resize text to fit any polygon

How to resize the text to fit in any given polygon in D3js ?
I need something like in the picture:
I found similar topics but no usable resolutions: too old/deprecated/examples not working.
This question essentially boils down to finding a maximal rectangle inside a polygon, in this case aligned with the horizontal axis and of fixed aspect ratio, which is given by the text.
Finding this rectangle in an efficient way is not an easy task, but there are algorithms available. For example, the largestRect method in the d3plus-library. The details of this algorithm (which finds a good but not an optimal rectangle) are described in this blog post.
With the coordinates of the rectangle, you can transform the text such that it is contained in the rectangle, i. e.
translate to the bottom left point of the rectangle and
scale by the ratio of the width of the rectangle and the width of the text.
If you don't want to add an additional library to your dependency list and the polygons you are considering are (almost) convex and not highly irregular, you could try to find a "satisfying rectangle" by yourself. Below, I did a binary search on rectangles centered around the centroid of the polygon. In each iteration I check wether the four corners are inside the polygon using the d3.polygonContains method of d3-polygon. The resulting rectangle is green for comparison. Of course, this would just be a starting point.
const dim = 500;
const svg = d3.select("svg").attr("width", dim).attr("height", dim);
const text = svg.append("text").attr("x", 0).attr("y", 0);
const polygon = svg.append("polygon").attr("fill", "none").attr("stroke", "blue");
const rectangle = svg.append("polygon").attr("fill", "none").attr("stroke", "red");
const rectangle2 = svg.append("polygon").attr("fill", "none").attr("stroke", "green");
d3.select("input").on("change", fitText);
d3.select("button").on("click", drawPolygon);
// Draw random polygon
function drawPolygon() {
const num_points = 3 + Math.ceil(7 * Math.random());
points = [];
for (let i = 0; i < num_points; i++) {
const angle = 2 * Math.PI / num_points * (i + 0.1 + 0.8 * Math.random());
const radius = dim / 2 * (0.1 + 0.9 * Math.random());
points.push([
radius * Math.cos(angle) + dim / 2,
radius * Math.sin(angle) + dim / 2,
])
}
polygon.attr("points", points.map(d => d.join()).join(' '));
fitText();
}
function fitText() {
// Set text to input value and reset transform.
text.text(d3.select("input").property("value")).attr("transform", null);
// Get dimensions of text
const text_dimensions = text.node().getBoundingClientRect();
const ratio = text_dimensions.width / text_dimensions.height;
// Find largest rectangle
const rect = d3plus.largestRect(points, {angle: 0, aspectRatio: ratio}).points;
// transform text
const scale = (rect[1][0] - rect[0][0]) / text_dimensions.width;
text.attr("transform", `translate(${rect[3][0]},${rect[3][1]}) scale(${scale})`);
rectangle.attr("points", rect.map(d => d.join()).join(' '));
// alternative
const rect2 = satisfyingRect(ratio);
rectangle2.attr("points", rect2.map(d => d.join()).join(' '));
}
function satisfyingRect(ratio) {
// center rectangle around centroid
const centroid = d3.polygonCentroid(points);
let minWidth = 0;
let maxWidth = d3.max(points, d => d[0]) - d3.min(points, d => d[0]);
let rect;
for (let i = 0; i < 20; i++) {
const width = 0.5 * (maxWidth + minWidth);
rect = [
[centroid[0] - width, centroid[1] - width / ratio],
[centroid[0] + width, centroid[1] - width / ratio],
[centroid[0] + width, centroid[1] + width / ratio],
[centroid[0] - width, centroid[1] + width / ratio]
]
if (rect.every(d => d3.polygonContains(points, d)))
minWidth = width;
else
maxWidth = width;
}
return rect;
}
let points;
drawPolygon();
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.3.0/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3plus-shape#1"></script>
<div>
<input type="text" value="lorem ipsum dolor">
<button>New polygon</button>
</div>
<svg></svg>

d3.js - Dendrogram display adjusted to the tree diagram

With d3.js I have created d3 dendrograms to visualize hierachicals relations between objects. Dimensions and margins of the graph are defined with fixed height and width values.
var width = 1000,
height = 800,
boxWidth = 150,
boxHeight = 35,
gap = {
width: 50,
height: 12
},
margin = {
top: 16,
right: 16,
bottom: 16,
left: 16
},
svg;
With a few relations, display is ok but with many relations it's doesn't fit, graph is 'cut' and I can't see the entire graph. How to set this width and height properties dynamically and adjusted to the size of the graph ?
An example with a correct display : Codepen
An example with an incorrect display : Codepen
Let's work this out, you need to know the bounding box of your content first and then adjust the svg size. To do that, in this particular case, you only have to look at the boxes or nodes and can ignore the links.
With that in mind you can do the following after populating the Nodes in your renderRelationshipGraph function and return the calculated value:
function renderRelationshipGraph(data) {
// ...
var bbox = Nodes.reduce(function (max, d)
{
var w = d.x + boxWidth;
var h = d.y + boxHeight;
if (w > max[0]) {max[0] = w}
if (h > max[1]) {max[1] = h}
return max
}, [0,0])
return bbox
}
then on the main code change use it to update height and width of the svg:
svg = d3.select("#tree").append("svg")
.attr("width", width)
.attr("height", height);
svg.append("g");
var bbox = renderRelationshipGraph(data);
svg.attr("width", bbox[0])
.attr("height", bbox[1]);
You can add a transition and limit the height but this does what you requested with a really large end result.

Can you "bend" a SVG around bezier path while animating?

Is there any way to "bend" an SVG object as its being animated along a bezier path? Ive been using mostly GSAP for animating things. The effect would look something like this: https://www.behance.net/gallery/49401667/Twisted-letters-2 (the one with the blue pencil). I have managed to get the red arrow to animate along the path but the shape stays the same the whole time. Id like for it to follow along the green path and bend as it goes around the curve so that at the end of the animation it has the shape of the purple arrow. Here is the codepen.
GSAP code:
var motionPath = MorphSVGPlugin.pathDataToBezier("#motionPath", {align:"#arrow1"});
var tl1 = new TimelineMax({paused:true, reversed:true});
tl1.set("#arrow1", {xPercent:-50, yPercent:-50});
tl1.to("#arrow1", 4, {bezier:{values:motionPath, type:"cubic"}});
$("#createAnimation").click(function(){
tl1.reversed() ? tl1.play() : tl1.reverse();
});
Is there a way to do this with just GSAP? Or will I need something like Pixi?
This is how I would do it:
First I need an array of points to draw the arrow and a track. I want to move the arrow on the track, and the arrow should bend following the track. In order to achieve this effect with every frame of the animation I'm calculating the new position of the points for the arrow.
Also: the track is twice as long as it seams to be.
Please read the comments in the code
let track = document.getElementById("track");
let trackLength = track.getTotalLength();
let t = 0.1;// the position on the path. Can take values from 0 to .5
// an array of points used to draw the arrow
let points = [
[0, 0],[6.207, -2.447],[10.84, -4.997],[16.076, -7.878],[20.023, -10.05],[21.096, -4.809],[25.681, -4.468],[31.033, -4.069],[36.068, -3.695],[40.81, -3.343],[45.971, -2.96],[51.04, -2.584],[56.075, -2.21],[60.838, -1.856],[65.715, -1.49],[71.077, -1.095],[75.956, -0.733],[80, 0],[75.956, 0.733],[71.077, 1.095],[65.715, 1.49],[60.838, 1.856],[56.075, 2.21],[51.04, 2.584],[45.971, 2.96],[40.81, 3.343],[36.068, 3.695],[31.033, 4.069],[25.681, 4.468],[21.096, 4.809],[20.023, 10.05],[16.076, 7.878],[10.84, 4.997],[6.207, 2.447],[0, 0]
];
function move() {
requestAnimationFrame(move);
if (t > 0) {
t -= 0.001;
} else {
t = 0.5;
}
let ry = newPoints(track, t);
drawArrow(ry);
}
move();
function newPoints(track, t) {
// a function to change the value of every point on the points array
let ry = [];
points.map(p => {
ry.push(getPos(track, t, p[0], p[1]));
});
return ry;
}
function getPos(track, t, d, r) {
// a function to get the position of every point of the arrow on the track
let distance = d + trackLength * t;
// a point on the track
let p = track.getPointAtLength(distance);
// a point near p used to calculate the angle of rotation
let _p = track.getPointAtLength((distance + 1) % trackLength);
// the angle of rotation on the path
let a = Math.atan2(p.y - _p.y, p.x - _p.x) + Math.PI / 2;
// returns an array of coordinates: the first is the x, the second is the y
return [p.x + r * Math.cos(a), p.y + r * Math.sin(a)];
}
function drawArrow(points) {
// a function to draw the arrow in base of the points array
let d = `M${points[0][0]},${points[0][1]}L`;
points.shift();
points.map(p => {
d += `${p[0]}, ${p[1]} `;
});
d += "Z";
arrow.setAttributeNS(null, "d", d);
}
svg {
display: block;
margin: 2em auto;
border: 1px solid;
overflow: visible;
width:140vh;
}
#track {
stroke: #d9d9d9;
vector-effect: non-scaling-stroke;
}
<svg viewBox="-20 -10 440 180">
<path id="track" fill="none"
d="M200,80
C-50,280 -50,-120 200,80
C450,280 450,-120 200,80
C-50,280 -50,-120 200,80
C450,280 450,-120 200,80Z" />
<path id="arrow" d="" />
</svg>

d3 area graph going out of bounds

I am struggling to keep the area graph inbounds for a particular set of data. I am not able to figure out what exactly is making it go out of range
var xRange = d3.scale.linear().range([MARGINS.left, WIDTH - MARGINS.right]).domain([0, numberOfDays + 1]),
yRange = d3.scale.linear().range([HEIGHT - MARGINS.top, MARGINS.bottom]).domain([_.min(areaData), _.max(areaData)]);
js fiddle here
https://jsfiddle.net/sahils/o7df3tyn/20/
Its due to you setting them exactly so these lines :
<svg id="visualisation" width="1200" height="400"></svg>
And
WIDTH = 1000,
HEIGHT = 400,
Rather than this just use window size :
var WIDTH = window.innerWidth,
HEIGHT = window.innerHeight,
vis = d3.select('#visualisation').attr('width', WIDTH).attr('height', HEIGHT)
And remove the styling from your html. Updated fiddle : https://jsfiddle.net/thatOneGuy/o7df3tyn/23/

d3.js: distance point to svg:path

Is there an (efficient) method to (a) calculate the shortest distance between a fixed point and a svg:path element in d3.js and (b) determine the point on the path which belongs to this distance?
In the general case, I don´t think so. An SVG path is a complex element. For instance, if the path is a Bezier curve, the control points may be off the represented line, and the represented shape may be off the bounding box of the control points.
I think that if you have a set of points that you use to generate the path, you may use this points to compute the distance from this points to a given point and get the minimum distance. In the MDN SVG Path Tutorial you can find some examples of complex shapes and how they are made.
Although my calculus answer is still valid, you could just do everything in this bl.ocks example:
var points = [[474,276],[586,393],[378,388],[338,323],[341,138],[547,252],[589,148],[346,227],[365,108],[562,62]];
var width = 960,
height = 500;
var line = d3.svg.line()
.interpolate("cardinal");
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
var path = svg.append("path")
.datum(points)
.attr("d", line);
var line = svg.append("line");
var circle = svg.append("circle")
.attr("cx", -10)
.attr("cy", -10)
.attr("r", 3.5);
svg.append("rect")
.attr("width", width)
.attr("height", height)
.on("mousemove", mousemoved);
function mousemoved() {
var m = d3.mouse(this),
p = closestPoint(path.node(), m);
line.attr("x1", p[0]).attr("y1", p[1]).attr("x2", m[0]).attr("y2", m[1]);
circle.attr("cx", p[0]).attr("cy", p[1]);
}
function closestPoint(pathNode, point) {
var pathLength = pathNode.getTotalLength(),
precision = pathLength / pathNode.pathSegList.numberOfItems * .125,
best,
bestLength,
bestDistance = Infinity;
// linear scan for coarse approximation
for (var scan, scanLength = 0, scanDistance; scanLength <= pathLength; scanLength += precision) {
if ((scanDistance = distance2(scan = pathNode.getPointAtLength(scanLength))) < bestDistance) {
best = scan, bestLength = scanLength, bestDistance = scanDistance;
}
}
// binary search for precise estimate
precision *= .5;
while (precision > .5) {
var before,
after,
beforeLength,
afterLength,
beforeDistance,
afterDistance;
if ((beforeLength = bestLength - precision) >= 0 && (beforeDistance = distance2(before = pathNode.getPointAtLength(beforeLength))) < bestDistance) {
best = before, bestLength = beforeLength, bestDistance = beforeDistance;
} else if ((afterLength = bestLength + precision) <= pathLength && (afterDistance = distance2(after = pathNode.getPointAtLength(afterLength))) < bestDistance) {
best = after, bestLength = afterLength, bestDistance = afterDistance;
} else {
precision *= .5;
}
}
best = [best.x, best.y];
best.distance = Math.sqrt(bestDistance);
return best;
function distance2(p) {
var dx = p.x - point[0],
dy = p.y - point[1];
return dx * dx + dy * dy;
}
}
path {
fill: none;
stroke: #000;
stroke-width: 1.5px;
}
line {
fill: none;
stroke: red;
stroke-width: 1.5px;
}
circle {
fill: red;
}
rect {
fill: none;
cursor: crosshair;
pointer-events: all;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
And I spent all that time in the previous answer writing up pretty LaTeX!
I'm not aware of a d3-specific solution to this. But if your path can be represented as a segment of a function, there is hope with a little calculus.
Start with the line length equation .
Plug in your point to x1 and y1.
Replace the remaining y with the function representing your path.
Simplify, then calculate the derivative .
Set to 0 and solve. Hopefully one x value will be within the bounds of your path endpoints. Apply this x to your path function and you have your point on the path.
A more visual example. There are plenty of considerations to make this happen in JavaScript: what is my function? What is the fastest way to take a derivative of the above? These are specific to your situation.

Resources