Calculate SVG Path Centroid with D3.js - javascript

I'm using the SVG located at http://upload.wikimedia.org/wikipedia/commons/3/32/Blank_US_Map.svg in a project and interacting with it with d3.js. I'd like to create a click to zoom effect like http://bl.ocks.org/2206590, however that example relies on path data stored in a JSON object to calculate the centroid. Is there any way to load path data in d3 from an existing SVG to get the centroid?
My (hackish) attempt so far:
function get_centroid(sel){
var coords = d3.select(sel).attr('d');
coords = coords.replace(/ *[LC] */g,'],[').replace(/ *M */g,'[[[').replace(/ *z */g,']]]').replace(/ /g,'],[');
return d3.geo.path().centroid({
"type":"Feature",
"geometry":{"type":"Polygon","coordinates":JSON.parse(coords)}
});
}
This seems to work on some states, such as Missouri, but others like Washington fail because my SVG data parsing is so rudimentary. Does d3 support something like this natively?

The D3 functions all seem to assume you're starting with GeoJSON. However, I don't actually think you need the centroid for this - what you really need is the bounding box, and fortunately this is available directly from the SVG DOM interface:
function getBoundingBoxCenter (selection) {
// get the DOM element from a D3 selection
// you could also use "this" inside .each()
var element = selection.node();
// use the native SVG interface to get the bounding box
var bbox = element.getBBox();
// return the center of the bounding box
return [bbox.x + bbox.width/2, bbox.y + bbox.height/2];
}
This is actually slightly better than the true centroid for the purpose of zooming, as it avoids some projection issues you might otherwise run into.

The accepted answer was working great for me until I tested in Edge. I can't comment since I don't have enough karma or whatever but was using this solution and found an issue with Microsoft Edge, which does not use x or y, just top/left/bottom/right, etc.
So the above code should be:
function getBoundingBoxCenter (selection) {
// get the DOM element from a D3 selection
// you could also use "this" inside .each()
var element = selection.node();
// use the native SVG interface to get the bounding box
var bbox = element.getBBox();
// return the center of the bounding box
return [bbox.left + bbox.width/2, bbox.top + bbox.height/2];
}

From here
The solution is to use the .datum() method on the selection.
var element = d3.select("#element");
var centroid = path.centroid(element.datum());

Related

fabric center all objects as group

I recently started to work with fabricjs, and I have a question about groups. I would like to center all objects (many) when canvas is resized. So I tought to build a group with all objects in my canvas and center it.
However, in order to do that, I have to:
retrieve all objects
add them to my group
remove them from my canvas
add the group to my canvas
A complex process, and, above all, my objects lost the possibility to be manipulate individually.
I am wondering if it is possible to manipulate a group without add it in my canvas?
This code should work with fabric 2.0 beta.
A similar code is possible for the version 1.7.x and you can create from this using the activeGroup logic.
What you may want to do is:
// After the canvas resize:
// get all Objects:
var objects = canvas.getObjects();
// create a multiselection
var selection = new fabric.ActiveSelection(objects, { canvas: canvas });
// now get size of this selection:
var width = selection.width;
var height = selection.height;
// scale the selection accordingly
var scale = ( find the right scale value, probably canvas.width/width or canvas.height/height. The biggest or smaller of them )
selection.scale(scale);
// center the selection in the canvas
selection.center();
// destroy the selection
selection.destroy();
And this should be working.
Do you need the group? Or you are just creating them to center the objects? If not, you can use object.center() for each one of the canvas object you want to center in screen.
canvas.forEachObject(function (o) {
o.center();
});

How to scale a shape using d3 and x3dom?

I am trying to create a 3d "chart" using d3 data-binding and x3dom.
The data-binding seems to work correctly and I am able to generate HTML which looks correct, but x3dom does not show the expected scale (as in the size of the shape is not scaled at all).
The code is fairly simple. First, I bind data points to transforms:
var t = scene.selectAll('transform').data(data);
var transform = t.enter();
var shape = transform.append('transform')
.attr('translation', function (d, i) {
...
}).append('shape');
Within each transform element created for a data-point, I use this code to create a box:
shape.append('box').attr('size', '1, 1, 1');
Then, I expect to scale the boxes later using the data, using a transition:
t.transition().attr('scale', function (d) {
return "1.0 1.0 " + d.size;
});
This only works if I run the whole function that does the above twice, the second time in a setInterval() function!
I am a newbie, so I am certainly missing something here, but the code does generate what I expect and does work if run twice, for whatever reason. So, why doesn't it just work first time?
Note: I could of course just set the size directly instead of using an animation to do that later by scaling the shapes, but that also didn't work. It seems that the shapes never get the correct size using either technique, unless I run them twice.

Find topojson feature by latitude longitude

The demo I'm working on is based on World tour, which uses canvas instead of SVG, so I cannot attach mouse event to country path to find what country was clicked. Is there any way to find which feature contains lat/long I get from mouse coordinates?
var canvas = d3.select("canvas")
.on("mousemove", function() {
var p = d3.mouse(canvas.node());
console.log(projection.invert(p)); // which country contains these coordinates?
});
var countries;
queue()
.defer(d3.json, 'world-110m.json.txt')
.defer(d3.tsv, 'world-country-names.tsv')
.await(function ready(error, world, names) {
countries = topojson.feature(world, world.objects.countries).features;
});
You best option is to create a hidden canvas that mirrors the visible one but draws each country in a different RGB color (a literal "bitmap"), and look up the corresponding feature by the color of the pixel at the mouse cursor's position.
You can see a working example of this here. I've kept the bitmap visible so you can see what's happening.
You do have some other options:
Use a library (such as Turf.js) to do point-in-polygon checks, e.g. with turf.inside.
As an optimization, construct a d3.geom.quadtree of all of the points in every feature and only perform the point-in-polygon checks for features inside the quadtree leaf node.

D3 + Leaflet: d3.geo.path() resampling

We've adapted Mike Bostock's original D3 + Leaflet example:
http://bost.ocks.org/mike/leaflet/
so that it does not redraw all paths on each zoom in Leaflet.
Our code is here: https://github.com/madeincluj/Leaflet.D3/blob/master/js/leaflet.d3.js
Specifically, the projection from geographical coordinates to pixels happens here:
https://github.com/madeincluj/Leaflet.D3/blob/master/js/leaflet.d3.js#L30-L35
We draw the SVG paths on the first load, then simply scale/translate the SVG to match the map.
This works very well, except for one issue: D3's path resampling, which looks great at the first zoom level, but looks progressively more broken once you start zooming in.
Is there a way to disable the resampling?
As to why we're doing this: We want to draw a lot of shapes (thousands) and redrawing them all on each zoom is impractical.
Edit
After some digging, seems that resampling happens here:
function d3_geo_pathProjectStream(project) {
var resample = d3_geo_resample(function(x, y) {
return project([ x * d3_degrees, y * d3_degrees ]);
});
return function(stream) {
return d3_geo_projectionRadians(resample(stream));
};
}
Is there a way to skip the resampling step?
Edit 2
What a red herring! We had switched back and forth between sending a raw function to d3.geo.path().projection and a d3.geo.transform object, to no avail.
But in fact the problem is with leaflet's latLngToLayerPoint, which (obviously!) rounds point.x & point.y to integers. Which means that the more zoomed out you are when you initialize the SVG rendering, the more precision you will lose.
The solution is to use a custom function like this:
function latLngToPoint(latlng) {
return map.project(latlng)._subtract(map.getPixelOrigin());
};
var t = d3.geo.transform({
point: function(x, y) {
var point = latLngToPoint(new L.LatLng(y, x));
return this.stream.point(point.x, point.y);
}
});
this.path = d3.geo.path().projection(t);
It's similar to leaflet's own latLngToLayerPoint, but without the rounding. (Note that map.getPixelOrigin() is rounded as well, so probably you'll need to rewrite it)
You learn something every day, don't you.
Coincidentally, I updated the tutorial recently to use the new d3.geo.transform feature, which makes it easy to implement a custom geometric transform. In this case the transform uses Leaflet’s built-in projection without any of D3’s advanced cartographic features, thus disabling adaptive resampling.
The new implementation looks like this:
var transform = d3.geo.transform({point: projectPoint}),
path = d3.geo.path().projection(transform);
function projectPoint(x, y) {
var point = map.latLngToLayerPoint(new L.LatLng(y, x));
this.stream.point(point.x, point.y);
}
As before, you can continue to pass a raw projection function to d3.geo.path, but you’ll get adaptive resampling and antimeridian cutting automatically. So to disable those features, you need to define a custom projection, and d3.geo.transform is an easy way to do this for simple point-based transformations.

Split groups in SVG into sub-images positioned around the contents

I'm having issues with individual elements with an imported SVG and was hoping somehow could advise.
I have an SVG created in Illustrator, with several layers, which end up being group elements.
When I retrieve the SVG from my server, I get something similar to.
<svg>
<g>
<g>
</svg>
I do not want to place the image as one, so I break it up by the groups, and surround each by their own svg tag and then place on the page.
<svg>
<g>
</svg>
<svg>
<g>
</svg>
This is great, works like I want.
My issue lies, where the paths of each of these items is drawn in the original file. They are all drawn off (0,0) from the Illustrator file, so when I try to place them, they all have a ton of white space area on the left, where the other elements once existed.
I've tried using transform="translate(-50,-50)" or whatever, which does shift the elements, but since they do not have x,y properties, I do not know where to shift them.
Does anyone know of a way to offset a path drawn? Or if there is a way to read the SVG and break it up into each individual element and work with?
When using firebug or chrome, they show me the individual element with correct sizes, but they are placed with lots of white space because of position of the paths drawn in Illustrator.
I've tried contentDocument, documentElement and both of these come up as null. Maybe I'm using them wrong?
I'm a heavy Actionscript developer, and now working with Javascript and jQuery so I'm use to x,y coord system and placing elements, but this is not seeming the way it should work :/
If you really want to get the XY parts of a path I have written a function to convert paths to use all-absolute commands. With this you could then run through the path commands (using the pathSegList DOM interface, not the raw string attribute) and pull out all the X/Y values and do with them what you will.
However, far easier is to simply calculate the bounding box of the path and set the viewBox on your <svg> element to fit around that directly:
// In case you want to leave the old SVG document unchanged
var newGroup = oldGroup.cloneNode(true);
var newSVG = document.createElementNS('http://www.w3.org/2000/svg','svg');
newSVG.appendChild(newGroup);
var bbox = newGroup.getBBox();
newSVG.setAttribute(
'viewBox',
[bbox.x,bbox.y,bbox.width,bbox.height].join(' ')
);
The above answer does not properly account for the case where a transform has already been applied to the group you want to move. (The bounding box is returned in the untransformed space of the element.) I've created a demo that accounts for this, calculating the correct bounding box for transformed elements.
Demo: http://phrogz.net/SVG/explode_svg_components.xhtml
// Find all the root groups in the original SVG file
var rootGroups = document.querySelectorAll('svg > g');
for (var i=rootGroups.length;i--;){
var newSVG = elementToSVG(rootGroups[i]);
document.body.appendChild(newSVG);
}
// Create a new SVG wrapping around a copy of the element
// with the viewBox set to encompass the element exactly
function elementToSVG(el){
var old = el.ownerSVGElement,
svg = document.createElementNS(old.namespaceURI,'svg'),
css = old.querySelectorAll('style,defs');
// Preserve elements likely needed for correct appearance
[].forEach.call(css,copyToNewSVG);
copyToNewSVG(el);
var bb = globalBoundingBox(el);
svg.setAttribute('viewBox',[bb.x,bb.y,bb.width,bb.height].join(' '));
return svg;
function copyToNewSVG(e){
svg.appendChild(e.cloneNode(true));
}
}
// Calculate the bounding box of an element in global SVG space
// accounting for transforms applied to the element
function globalBoundingBox(el){
var bb = el.getBBox(),
svg = el.ownerSVGElement,
m = el.getTransformToElement(svg);
var pts = [
svg.createSVGPoint(), svg.createSVGPoint(),
svg.createSVGPoint(), svg.createSVGPoint()
];
pts[0].x=bb.x; pts[0].y=bb.y;
pts[1].x=bb.x+bb.width; pts[1].y=bb.y;
pts[2].x=bb.x+bb.width; pts[2].y=bb.y+bb.height;
pts[3].x=bb.x; pts[3].y=bb.y+bb.height;
var xMin=Infinity,xMax=-Infinity,yMin=Infinity,yMax=-Infinity;
pts.forEach(function(pt){
pt = pt.matrixTransform(m);
xMin = Math.min(xMin,pt.x);
xMax = Math.max(xMax,pt.x);
yMin = Math.min(yMin,pt.y);
yMax = Math.max(yMax,pt.y);
});
bb = {}; //IE9 disallows mutation of the original bbox
bb.x = xMin; bb.width = xMax-xMin;
bb.y = yMin; bb.height = yMax-yMin;
return bb;
}

Categories