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

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.

Related

Zooming in a D3 visualization that uses WebGL through stardustjs

I'm currently making a force network visualization that involves a large number of nodes and edges (over 50k+) using a new library called stardust.js. Stardust uses WebGL to make the rendering of nodes and edges much quicker than Canvas/D3.
However, I am unable to figure out how to add zoom and pan to this visualization.
According to this thread on stardust's google group, the creator of the stardust library mentions that there is no support for zoom and pan right now, but it is possible to implement this by setting in the mark specification by setting zoom and pan specifications as parameters.
import { Circle } from P2D;
mark MyMark(translateX: float, translateY: float, scale: float, x: float, y: float, radius: float) {
Circle(Vector2(x * scale + translateX, y * scale + translateY), radius);
}
https://stackoverflow.com/editing-help
// In the js code you can do something like:
marks.attr("translateX", 10).attr("translateY", 15).attr("scale", 2);
This library uses a kind of Typescript language where one defines "marks" (which is what all the nodes and edges are), and it should be possible to define these marks with the above parameters. But how can one implement this?
Is there an easier way to do this? Can one add a library like Pixi.js on to this visualization to make it zoom and pan?
There is no need to define custom marks (it can be done with custom marks).
The position of the objects is controlled by a Stardust.scale().
var positions = Stardust.array("Vector2")
.value(d => [d.x, d.y])
.data(nodes);
var positionScale = Stardust.scale.custom("array(pos, value)")
.attr("pos", "Vector2Array", positions)
By modifying the value function you can zoom and translate.
By attaching the zoom to the canvas the star dust drag is no longer working. But that is a different problem.
I used the example https://stardustjs.github.io/examples/graph/
In the zoom callback save the zoom parameters and request a new render of the graph.
var fps = new FPS();
var zoom_scale = 1.0, zoom_t_x = 0.0, zoom_t_y = 0.0;
d3.select(canvas).call(d3.zoom().on("zoom", zoomed));
function zoomed() {
zoom_scale = d3.event.transform.k;
zoom_t_x = d3.event.transform.x;
zoom_t_y = d3.event.transform.y;
requestRender();
}
function render() {
positions.value(d => [d.x*zoom_scale + zoom_t_x, d.y*zoom_scale + zoom_t_y]);
......
}
The example contains an error.
When you use a slider the simulation never stops because the alphaTarget is set to 0.3.
force.alphaTarget(0.3).restart();
It should be changed to
force.alpha(0.3).alphaTarget(0).restart();

Why does D3 not render TopoJSON correctly?

I'm trying to render a map of Switzerland with D3.js and TopoJSON. The underlying JSON can be found here. First I tried to follow this tutorial and after I couldn't render anything looking remotely like a map, I found this question on here with a link to a working example. From where I took this code I'm currently using:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="http://d3js.org/d3.v3.min.js" charset="utf-8"></script>
<script src="http://d3js.org/topojson.v1.min.js"></script>
<style>
path {
fill: #ccc;
}
</style>
</head>
<body>
<h1>topojson simplified Switzerland</h1>
<script>
var width = window.innerWidth,
height = window.innerHeight;
var svg = d3.select("body")
.append("svg")
.attr("width", width)
.attr("height", height);
var projection = d3.geo.mercator()
.scale(500)
.translate([-900, 0]);
var path = d3.geo.path()
.projection(projection);
d3.json("ch-cantons.json", function(error, topology) {
if (error) throw error;
svg.selectAll("path")
.data(topojson.feature(topology, topology.objects.cantons).features).enter().append("path").attr("d", path);
});
</script>
</body>
</html>
Since the JSON looks ok (checked on some online platforms that renders it properly after copy/pasting) and there is only little code that could go wrong, I assume the error is in the projection parameters. Fiddled around a bit couldn't make it work. So any help would be very much appreciated!
You are right, the error is in the projection.
But, the error depends on if your data is projected or unprojected (lat long pairs).
Unprojected Data
If you have data that is in WGS84 - that is to say lat long pairs, then you have this problem:
Using your projection, but changing only the data source I get something like this (I shaved off the empty ocean on the right):
To center a Mercator properly, you need to know the center coordinate of your area of interest. This generally can be fairly general, for Switzerland I might try 47N 8.25E.
Once you have this coordinate you need to place it in the middle. One way is to rotate on the x axis and center on the y:
var projection = d3.geo.mercator()
.scale(3500)
.rotate([-8.25,0])
.center([0,47])
.translate([width/2,height/2])
Note that the x rotation is negative, you are spinning the globe underneath the projection.
The other option is to rotate on both x and y, this is likely the preferred option as you approach the poles - as Mercator distortion becomes unworkable at high latitudes. This approach would look like:
var projection = d3.geo.mercator()
.scale(4000)
.rotate([-8.25,-47])
.center([0,0])
.translate([width/2,height/2])
Note again the negative rotation values. The second option requires a higher scale value as this method essentially treats Switzerland as though it were at zero,zero on a Mercator projection - and along the equator land sizes are minimized.
Using the second of these projections, I get:
So you'll have to dial in the scale a bit, but now you should be able to see your data (assuming your data is in proper lat long pairs).
Projected Data
Based on the comment below, which includes a linked json file, we can see that this is your problem.
There are two potential solutions to this:
Convert the data to lat long pairs
Use a geoTransform
Option one is the easiest, you'll unproject the data - which requires knowing the current projection. In GIS software this will generally be projecting it as WGS84, which is arguably not really a projection but a datum. Once you have your lat long pairs, you follow the steps above for unprojected data.
Option two skips a d3.geoProjection altogether. Instead, we'll create a transform function that will convert the projected coordinates to the desired SVG coordinates.
A geo projection looks like:
function scale (scaleFactor) {
return d3.geo.transform({
point: function(x, y) {
this.stream.point(x * scaleFactor, (y * scaleFactor);
}
});
}
And is used like a projection:
var path = d3.geo.path().projection(scale(0.1));
It simply takes a stream of x,y coordinates that are already cartesian and transforms them in a specified manner.
To translate the map so it is centered you'll need to know the center coordinate. You can find this with path.bounds:
var bounds = path.bounds(features);
var centerX = (bounds[0][0] + bounds[1][0])/2;
var centerY = (bounds[0][1] + bounds[1][1])/2;
Path.bounds returns the top left corner and bottom right corner of a feature. This is the key part, you can make an autoscaling function, there are plenty of examples out there, but I like manually scaling often. If the map is centered, this is easy. For your map, your geoTransform might look like:
function scale (scaleFactor,cx,cy,width,height) {
return d3.geo.transform({
point: function(x, y) {
this.stream.point((x-cx) * scaleFactor + width/2, (y-cy) * scaleFactor +height/2);
}
});
}
Here cx and cy refer to the middle of your feature and the width and height refer to the width and height of the svg element - we don't want features clustered at SVG point [0,0].
Altogether, that gives us something like (with a scale factor of 0.002):
Here's an updated JSbin: http://jsbin.com/wolamuzeze/edit?html,output
Keep in mind that scale is dependent on window size as your width/height are relative to window size in your case. This might be best addressed with automatically setting the zoom level, though this can create problems if you have labels (for example).
This answer might help as well: Scaling d3 v4 map to fit SVG (or at all)

syncing d3.js with THREE.js earth

I am trying to combine WebGL earth with d3.geo.satellite projection.
I have managed to to overlay the 2 projections on top of each other and sync rotation, but I am having trouble to sync zooming. When I sync them to match size, WebGL projection gets deformed, but the d3.geo.satellite remains the same. I have tried different combination of projection.scale, projection.distance without much success.
Here is JS fiddle (it take a little while to load the resources). You can drag it to rotate (works well). But if you zoom in (use mousewheel) you can see the problem.
https://jsfiddle.net/nxtwrld/7x7dLj4n/2/
The important code is at the bottom of the script - the scale function.
function scale(){
var scale = d3.event.scale;
var ratio = scale/scale0;
// scale projection
projection.scale(scale);
// scale Three.js earth
earth.scale.x = earth.scale.y = earth.scale.z = ratio;
}
I do not using WebGL earth either , checking on your jsfiddle is not working anymore, and my assumption of your problem that you want to integrated D3.js with Threejs as a solution for 3d globe.
May I suggest you to try earthjs as your solution. Under the hood it use D3.js v4 & Threejs revision 8x both are the latest, and it can combine between Svg, canvas & threejs(WebGL).
const g = earthjs({padding:60})
.register(earthjs.plugins.mousePlugin())
.register(earthjs.plugins.threejsPlugin())
.register(earthjs.plugins.autorotatePlugin())
.register(earthjs.plugins.dropShadowSvg(),'dropshadow')
.register(earthjs.plugins.worldSvg('../d/world-110m.json'))
.register(earthjs.plugins.globeThreejs('../globe/world.jpg'))
g._.options.showLakes = false;
g.ready(function(){
g.create();
})
above snippet code you can run it from here.

How to draw Tissot's ellipse (Tissot's indicatrix) in Leaflet

OpenLayers supports tissot's ellipses natively by adding a sphere to the circular() method.
Unfortunately the Leaflet L.circle() does not support such feature.
How do I draw tissot's ellipses with Leaflet?
EDIT 3:
New proposition using leaflet-geodesy which seems a perfect fit for your need. It is exempt from Turf's bug (see Edit 2 below).
The API is quite simple:
LGeo.circle([51.441767, 5.470247], 500000).addTo(map);
(center position in [latitude, longitude] degrees, radius in meters)
Demo: http://jsfiddle.net/ve2huzxw/61/
Quick comparison with nathansnider's solution: http://fiddle.jshell.net/58ud0ttk/2/
(shows that both codes produce the same resulting area, the only difference being in the number of segments used for approximating the area)
EDIT: a nice page that compares Leaflet-geodesy with the standard L.Circle: https://www.mapbox.com/mapbox.js/example/v1.0.0/leaflet-geodesy/
EDIT 2:
Unfortunately Turf uses JSTS Topology Suite to build the buffer. It looks like this operation in JSTS does not fit a non-plane geometry like the Earth surface.
The bug is reported here and as of today the main Turf library does not have a full workaround.
So the below answer (edit 1) produces WRONG results.
See nathansnider's answer for a workaround for building a buffer around a point.
EDIT 1:
You can easily build the described polygon by using Turf. It offers the turf.buffer method which creates a polygon with a specified distance around a given feature (could be a simple point).
So you can simply do for example:
var pt = {
"type": "Feature",
"properties": {},
"geometry": {
"type": "Point",
"coordinates": [5.470247, 51.441767]
}
};
var buffered = turf.buffer(pt, 500, 'kilometers');
L.geoJson(pt).addTo(map);
L.geoJson(buffered).addTo(map);
Demo: http://jsfiddle.net/ve2huzxw/41/
Original answer:
Unfortunately it seems that there is currently no Leaflet plugin to do so.
It is also unclear what the Tissot indicatrix should represent:
A true ellipse that represents the deformation of an infinitely small circle (i.e. distortion at a single point), or
A circular-like shape that represents the deformation of a finite-size circle when on the Earth surface, like the OpenLayers demo you link to?
In that demo, the shape in EPSG:4326 is not an ellipse, the length in the vertical axis decreases at higher latitude compared to the other half of the shape.
If you are looking for that 2nd option, then you would have to manually build a polygon that represents the intersection of a sphere and of the Earth surface. If my understanding is correct, this is what the OL demo does. If that is an option for you, maybe you can generate your polygons there and import them as GeoJSON features into Leaflet? :-)
Because turf.buffer appears to have a bug at the moment, here is a different approach with turf.js, this time using turf.destination rotated over a range of bearings to create a true circle:
//creates a true circle from a center point using turf.destination
//arguments are the same as in turf.destination, except using number of steps
//around the circle instead of fixed bearing. Returns a GeoJSON Polygon as output.
function tissotish(center, radius, steps, units) {
var step_angle = 360/steps;
var bearing = -180;
var tissot = { "type": "Polygon",
"coordinates": [[]]
};
for (var i = 0; i < steps + 1; ++i) {
var target = turf.destination(center, radius, bearing, units);
var coords = target.geometry.coordinates;
tissot.coordinates[0].push(coords);
bearing = bearing + step_angle;
}
return tissot;
}
This doesn't produce a true Tissot's indicatrix (which is supposed to measure distortion at a single point), but it does produce a true circle, which from your other comments seems to be what you're looking for anyway. Here it is at work in an example fiddle:
http://fiddle.jshell.net/nathansnider/58ud0ttk/
For comparison, I added the same measurement lines here as I applied to ghybs's answer above. (When you click on the lines, they do say that the horizontal distance is greater than 500km at higher latitudes, but that is because, the way I created them, they end up extending slightly outside the circle, and I was too lazy to crop them.)

Calculate SVG Path Centroid with D3.js

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

Categories