I am drawing a map with D3.js importing it from a topojson file.
In order to draw it I user the following code:
d3.json(input_file, function(error, data) {
var units = topojson.feature(data, data.objects.countries),
view = d3.select("#map_view");
// ... SETUP OF THE PROJECTION AND PATH
view.selectAll("path")
.data(units.features)
.enter()
.append("path")
.attr("d", path);
}
This code will plots all the shapes defined in the input topojson file.
Now I want to remove few outlier small islands which I don't need which are located in a specified region in spherical coordinates.
Since now units object defines everything that is drawn, I tried to remove the shapes in the following way:
I identify the index of the country in the features list and index of the arc that lies in the desired region and remove the arc from the array:
var coordinates = units.features[5].geometry.coordinates,
outlierId = 23;
coordinates.splice(outlierId, 1);
Now when I check the coordinates array of this country I see no arc with coordinates in the excluded region.
But it is still drawn on the map although the modified units object enters the .data() method. It looks like information about the shapes is coming from somewhere else. How can it be?
Related
I am currently working on a project where I want to visualize airport locations in the USA on a geoJSON map and where the state is encoded by color.
So far I have managed to import my geoJSON file of US borders and my csv file of my airports dataset just fine. Furthermore, I have been able to visualize the map and adjusted the styling to my liking with the help of these ressources:
Observable - Making maps in D3
Making Bubble Maps in D3
Now I want to add the airport locations. My csv file that contains the list of airports includes a state abbreviation and latitude/longitude coordinates to help with the mapping. Since the first ressource mentioned above uses geoJSON to add the data, which is not available for me since I only have an csv file, I decided to go with the second tutorial to add my data to the map. However, when I try to project my longitude and latitude coordinates to carthesian coordinates, like in the example, nothing happens.
Just to check I have written a piece of code that logs the projected longitude and latitude arrays in the console and I realized that the function does indeed return an array of length 2 for each coordinate, but instead of storing a numerical value I get 'NaN' which does not cause an error when trying to add the data to the map but also doesn't make any dots show up on my map, naturally.
I have tried to look for solutions but couldn't find anything regarding this issue. The projection function works fine when using it for the map, so i don't quite understand why it does not work for the dataset as well.
You can take a look at my js file below. I had to remove the links for the actual datasets because they belong to my university and I am pretty sure I am not allowed to make them public. I also removed some code that I considered redundant from the snippet like specific values to allow for a better overview.
Also, I use D3 v5 (requirement from my university).
/* ===== Draw Map of the United States Part begins here ===== */
/* ===== USStates GeoJson =====*/
//load US states geo.json file from assets folder
d3.json("Imagine_Actual_link_here.json")
.then(function(states){
/* ===== Create svg canvas for said file =====*/
// set size of canvas
// specifies height and width of the canvas
// Create the svg Element
let svg = d3.select(".map")
.append("svg")
.attr("width", width)
.attr("height", height);
// Append empty placeholder g element to the SVG canvas
svg.append("g");
/* ===== Set Up Projection an draw paths ===== */
// Projection
var projection = d3.geoAlbers()
//scaling, rotating, etc.
// Create GeoPath that draws path
var dataGeoPath = d3.geoPath()
.projection(projection);
svg.selectAll("path")
.data(states.features)
.enter()
.append("path")
.attr("fill", "#ccc")
.attr("stroke", "#fff")
.attr("d", dataGeoPath);
/* ===== Draw Map of the United States Part end here ===== */
/* ===== Visualize the flight data Part begins here ===== */
/* ===== Load the data sets from the assets folder ===== */
// Load Airports data
d3.csv("Imagine_Another_link_here.html").then(function(airports){
//Create Colour Scale for all 50 States
var color = d3.scaleOrdinal()
// color scale for the states, not important right now, priority is fixing the coordinate issue
// Everything works fine so far!
// Now this is where things get complicated!
// Example loop to check return of projection function
airports.forEach((d) => {
console.log(projection([+d.longitude]), projection([+d.latitude]));
});
// Append Airports data to map
svg
.selectAll("myCircles")
.data(airports)
.enter().append("circle")
.attr("cx", function(d){ return projection([+d.longitude, +d.latitude])[0]}) //!! tries to add NaN as a value for x-axis
.attr("cy", function(d){ return projection([+d.longitude, +d.latitude])[1]}) //!! tries to add NaN as a value for the y axis
.attr("stroke-width", 1)
.attr("fill-opacity", .4)
//.style("fill", function(d){ return color(d.state)})
//.attr("stroke", function(d){ return color(d.state)})
});
/* ===== Visualize the flight data Part ends here ===== */
// Return the visualisation of the map
return svg.node();
});
Feel free to ask any questions if anything about my code is unclear.
You need to pass a two-element array to the projection:
console.log(projection([+d.longitude, +d.latitude])
I am using a tutorial to learn how to generate maps in D3.v3, but I am using D3.v4. I am just trying to get some circles to appear on the map (see below). The code works except that the circles are over Nevada and should be in the Bay Area. I imagine this is a mismatch between projections of the map and the projected coordinates. I am not sure what projection the map is in, but I have tried to force it to be albersUsa (see commented out commands where I generate path) but this causes the entire map to disappear. Any help would be appreciated!
<!DOCTYPE html>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/topojson.v2.min.js"></script>
<script>
var w = 960,
h = 600;
var projection = d3.geoAlbersUsa();
var path = d3.geoPath()
//.projection(projection)
d3.json("https://d3js.org/us-10m.v1.json", function(error, us) {
if (error) throw error;
var svg = d3.select("body").append("svg")
.attr("width", w)
.attr("height", h);
svg.selectAll("path")
.data(topojson.feature(us, us.objects.states).features)
.enter().append("path")
.attr("class", "states")
.attr("d", path);
svg.append("path")
.attr("class", "state-borders")
.attr("d", path(topojson.mesh(us, us.objects.states)))
svg.append("path")
.attr("class", "county-borders")
.attr("d", path(topojson.mesh(us, us.objects.counties)));
aa = [-122.490402, 37.786453];
bb = [-122.389809, 37.72728];
svg.selectAll("circle")
.data([aa,bb]).enter()
.append("circle")
.attr("cx", function (d) { return projection(d)[0]; })
.attr("cy", function (d) { return projection(d)[1]; })
.attr("r", "8px")
.attr("fill", "red")
});
</script>
Your US json is already projected, and to show it you use a null projection:
var path = d3.geoPath()
//.projection(projection)
Without defining a projection, your topojson/geojson coordinates will be translated to straight pixel coordinates. It just so happens that this particular topojson file has pixel coordinates that are within [0,0] and [960,600], almost the same size as a default bl.ock view. Without knowing the projection used too create that file you cannot replicated that projection to align geographic features to your data. Unless you place your features with pixel values directly and skip the projection altogether (not useful for points not near identifiable landmarks or where precision matters).
Your US topojson features disappear when projecting with a geoUsaAlbers() because you are taking pixel coordinates on a plane and transforming them to svg coordinates as though they were points on a three dimensional globe (d3 projections expect latitude longitude pairs).
Instead, use a topojson or geojson that is unprojected. That is to say it contains latitude/longitude pairs and project that data along with your points. See this bl.ock for an example with unprojected (lat/long pairs) json for the US using your code (but assigning a projection to path).
To check if you have latitude/longitude pairs you can view the geometry of these features in a geojson file easily and see if the values are valid long, lat points. For topojson, the topojson library converts features to geojson, so you can view the geometries after this conversion.
Here's an unprojected topojson of the US: https://bl.ocks.org/mbostock/raw/4090846/us.json
Let's say you really wanted to use the same topojson file though, well we can probably deduce the projection it uses. First, I'll show the difference between your projected points (by using an unprojected outline of the US) and the already projected topojson (the unprojected topojson is projected with d3.geoAlbersUsa() and the projected with a null projection):
Chances are the projection d3.geoAlbersUsa is optimized for a bl.ocks.org default viewport, 960x500. The unprojected dataset has a bounding box of roughly 960x600, so perhaps if we increase the scale by a factor of 600/500 and adjust the translate we can align our features in an svg that is 960x600:
var projection = d3.geoAlbersUsa();
var scale = projection.scale() * 600 / 500;
projection.scale(scale).translate([960/2,600/2])
var projectedPath = d3.geoPath().projection(projection);
And, this appears to align fairly well, I can't see the difference between the two:
Here's a block showing the aligned features.
But as I mention in the comments, even if you can align the features:
any zoom or centering would be problematic as you need to use a geoTransform on already projected data but a geoProjection on the raw geographic data. Using all (uniformly) projected data or all unprojected data makes life simpler.
I downloaded .geojson files from mapzen metro extracts that is supposed to show the outline of a neighborhood. However, when I run the javascript code that I have written, nothing is appended to the "g" element and thus nothing shows up.
Here is the code that I have now:
var canvas = d3.select("body").append("svg")
.attr("width", 760)
.attr("height", 700);
d3.json("wayland.geojson", function (data){
console.log(data);
var nb = canvas.append("g")
.attr("class","nb");
var group = nb.selectAll("path")
.data(data)
.enter()
.append("path")
.attr("d", d3.geoPath());
});
The geojson file in question is valid and is a Feature, so I was just wondering how to map such a file correctly.
Object {id: 85854865, type: "Feature", properties: Object, bbox: Array(4), geometry: Object}
You need to define a projection for d3.geoPath(), if you do not specify a projection, d3 defaults to a null projection. A null projection takes geographic coordinates and simply turns them into svg/canvas coordinates with no transformation at all. Thus, if you have lat/long pairs in your geojson, only points from 0,0 to 90,180 will show, so anything in the western or sourthern hemispheres will not work. However, no errors will be produced because the null projection and the geoPath are working as expected.
Note, d3 projections take coordinates that use the WGS84 datum, that is latitude longitude pairs using the WGS84 ellipsoid, generally most lat long pairs will use this datum (GPS, google earth, etc). If your data is projected already, then d3 geoProjections are not what you need, you'll need geoTransforms
Instead of using a null projection, try to define a projection. There are a few ways to do this, one is the fitExtent method (as you are using d3 v4):
var projection = d3.geoMercator()
.fitExtent([[40,40],[width-40,height-40]], geojson);
This will take a set of geojson features (not topojson) and place a 40 pixel buffer around the features using a mercator map projection.
Another option is to look at your bbox coordinates and find the center of your area of interest (or use Google Earth etc) and set the projection manually:
var projection = d3.geoAlbers()
.center([0,y])
.rotate([-x,0])
.scale(10000);
Scale values increase as you zoom in, so starting with a low value is always useful to ensure you are looking in the right area. Neighborhood level details will be very zoomed in though.
Projection type won't be too important at a neighborhood level, but setting the parameters correctly will be.
Lastly, you'll need to make sure your geoPath uses your projection:
geoPath.projection(projection);
I am trying to make this map of the us scale smaller. Either to my SVG, or even manually.
This is my code in its simplest from:
function initializeMapDifferent(){
var svg = d3.select("#map").append("svg")
.attr("width", 1000)
.attr("height", 500);
d3.json("https://d3js.org/us-10m.v1.json", function (error, us){
svg.append("g")
.attr("class", "states")
.selectAll("path")
.data(topojson.feature(us, us.objects.states).features)
.enter().append("path")
.attr("fill", "gray")
.attr("d", d3.geoPath());
});
}
I have tried something like:
var path = d3.geoPath()
.projection(d3.geoConicConformal()
.parallels([33, 45])
.rotate([96, -39])
.fitSize([width, height], conus));
but every time I add anything to my path variable I get NAN errors from the internal parts of D3. Thanks for any help!
Why the data doesn't project properly
The key issue is that your data is already projected. D3 geoProjections use data that is unprojected, or in lat long pairs. Data in the WGS84 datum. Essentially a d3 geoProjection takes spherical coordinates and translates them into planar cartesian x,y coordinates.
Your data does not conform to this - it is already planar. You can see most evidently because Alaska is not where it should be (unless someone changed the lat long pairs of Alaska, which is unlikely). Other signs and symptoms of already projected data may be a feature that covers the entire planet, and NaN errors.
That this is a composite projection makes it hard to unproject, but you can display already projected data in d3.js.
"Projecting" already projected data
Null Projection:
Most simply, you can define your projection as null:
var path = d3.geoPath(null);
This will take the x,y data from the geojson geometries and display it as x,y data. However, if your x,y coordinates exceed the width and height of your svg, the map will not be contained within your svg (as you found in your example with .attr("d", d3.geoPath());).
The particular file in this question is pre-projected to fit a 960x600 map, so this is ideal for a null projection - it was designed with the dimensions in mind. Its units are pixels and all coordinates fall within the desired dimensions. However, most projected geometries use coordinate systems with units such as meters, so that the bounding box of the feature's coordinates may be millions of units across. In these cases the null projection won't work - it'll convert a map unit value to a pixel value with no scaling.
With d3, A null projection is commonly used with geojson/topojson that is preprojected to fit a specified viewport using a d3 projection. See command line cartography for an example (the example uses unprojected source files - the same issues that arise from using a d3 projection on projected data apply in both browser and command line). The primary advantage of preprojecting a file for use with a null projection is performance.
geoIdentity
If all you need is to scale and center the features, you can use a geoIdentity. This is implements a geoTransform but with standard projection methods such as scale, translate, and most importantly - fitSize/fitExtent. So, we can set the projection to a geoIdentity:
var projection = d3.geoIdentity();
This currently does the same as the null projection used above, it takes x,y data from the geojson geometries and displays it as x,y data with no transform - treating each coordinate in the geojson as a pixel coordinate. But, we can apply fitSize to this (or fitExtent) which will automatically scale and translate the data into the specified bounding box:
var projection = d3.geoIdentity()
.fitSize([width,height],geojsonObject);
or
var projection = d3.geoIdentity()
.fitExtent([[left,top],[right,bottom]], geojsonObject);
Keep in mind, most projected data uses geographic conventions, y=0 is at the bottom, with y values increasing as one moves north. In svg/canvas coordinate space, y=0 is at the top, with y values increasing as one moves down. So, we will often need to flip the y axis:
var projection = d3.geoIdentity()
.fitExtent([width,height],geojsonObject)
.reflectY(true);
This particular dataset: https://d3js.org/us-10m.v1.json was projected with a d3 projection, so its y axis has already been flipped as d3 projections project to a svg or canvas coordinate space.
geoIdentity Demo
var width = 600;
var height = 300;
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
d3.json("https://d3js.org/us-10m.v1.json", function (error, us){
var featureCollection = topojson.feature(us, us.objects.states);
var projection = d3.geoIdentity()
.fitExtent([[50,50],[600-50,300-50]], featureCollection)
var path = d3.geoPath().projection(projection)
svg.append("g")
.attr("class", "states")
.selectAll("path")
.data(featureCollection.features)
.enter().append("path")
.attr("fill", "gray")
.attr("d", path);
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.6.0/d3.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/2.2.0/topojson.js"></script>
geoTransform
If you want a little more control over how that data is displayed you can use a geoTransform.
From Mike Bostock:
But what if your geometry is already planar? That is, what if you just
want to take projected geometry, but still translate or scale it to
fit the viewport?
You can implement a custom geometry transform to gain complete control
over the projection process.
To use a geoTransform is relatively straightforward assuming that you do not want to change the type of projection. For example, if you want to scale the data you could implement a short function for scaling with geoTransform:
function scale (scaleFactor) {
return d3.geoTransform({
point: function(x, y) {
this.stream.point(x * scaleFactor, y * scaleFactor);
}
});
}
var path = d3.geoPath().projection(scale(0.2));
Though, this will scale everything into the top left corner as you zoom out. To keep things centered, you could add some code to center the projection:
function scale (scaleFactor,width,height) {
return d3.geoTransform({
point: function(x, y) {
this.stream.point( (x - width/2) * scaleFactor + width/2 , (y - height/2) * scaleFactor + height/2);
}
});
}
var path = d3.geoPath().projection(scale(0.2,width,height))
geoTransform Demo:
Here is an example using your file and a geoTransform:
var width = 600;
var height = 300;
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
function scale (scaleFactor,width,height) {
return d3.geoTransform({
point: function(x, y) {
this.stream.point( (x - width/2) * scaleFactor + width/2 , (y - height/2) * scaleFactor + height/2);
}
});
}
d3.json("https://d3js.org/us-10m.v1.json", function (error, us){
var path = d3.geoPath().projection(scale(0.2,width,height))
svg.append("g")
.attr("class", "states")
.selectAll("path")
.data(topojson.feature(us, us.objects.states).features)
.enter().append("path")
.attr("fill", "gray")
.attr("d", path);
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.6.0/d3.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/2.2.0/topojson.js"></script>
Unproject the data
This method is useful under certain circumstances. But it requires you to know the projection that was used to create your data. Using QGIS/ArcGIS or even mapshaper you can change the data's projection so that it is "projected" as WGS84 (aka EPSG 4326). Once converted you have unprojected data.
In Mapshaper this is pretty easy with shapefiles, drag in the .dbf, .shp, and .prj files of a shapefile into the window. Open the console in mapshaper and type proj wgs84.
If you don't know the projection used to create the data, you can't unproject it - you don't know what transformation was applied and with what parameters.
Once unprojected, you can use regular d3 projections as normal as you have coordinates in the correct coordinate space: longitude latitude pairs.
Unprojecting is useful if you also have unprojected data and want to mix both in the same map. Alternatively you could project the unprojected data so that both use the same coordinate system. Combining unmatched coordinate systems in a map with d3 is not easy and d3 is likely not the correct vehicle for this. If you really want to replicate a specific projection with d3 to match features that are already projected with unprojected features, then this question may be useful.
How can you tell if your data is projected already?
You could do check to see that the geometry of your features respect the limits of latitude and longitude. For example, if you were to log:
d3.json("https://d3js.org/us-10m.v1.json", function (error, us){
console.log(topojson.feature(us, us.objects.states).features);
});
You will quickly see that values are in excess of +/- 90 degrees N/S and +/- 180 degrees E/W. Unlikely to be lat long pairs.
Alternatively, you could import your data to an online service such as mapshaper.org and compare against another topojson/geojson that you know is unprojected (or 'projected' using WGS84).
If dealing with geojson, you may be lucky enough to see a property that defines the projection, such as: "name": "urn:ogc:def:crs:OGC:1.3:CRS84" (CRS stands for coordinate reference system) or an EPSG number: EPSG:4326 (EPSG stands for European Petroleum Survey Group).
Also, if your data projects with a null projection but not a standard projection (scaled/zoomed out to ensure you aren't looking in the wrong area), you might be dealing with projected data. Likewise if your viewport is entirely covered by one feature (and you aren't zoomed in). NaN coordinates are also a potential indicator. However, these last indicators of projected data can also mean other problems.
Lastly, the data source may also indicate data is already projected either in meta data or how it is used: Looking at this block, we can see that no projection was used when the geoPath is defined.
I have a bunch of data that is coded with the Census FIPS code for states and counties (i.e. New York is FIPS 36, Kings County is FIPS 36047). I'm mapping that data using the d3.geo.albersUSA projection from the TopoJSON file here, which uses FIPS codes as the IDs for the state and county features. This is great for choropleths, where I just need to join on ID.
However, I want to draw lines from the centroid of one feature to another using the path.centroid(feature) and the LineString path type. Here's a simplified example of my data:
Start_State_FIPS, End_State_FIPS, Count_of_things
2,36,3
1,36,13
5,36,5
4,36,8
6,36,13
8,36,3
I'm using this same data to plot circles on the map, using the count_of_things field to set the radius. That's working no problem. To set up the lines, I created a map var with the FIPS code and the feature centroid, then used the FIPS code key to pull the start-end points from my data.
My code is drawing lines, but definitely not between centroid points. I didn't think I needed to do anything with the projection of the points, since they're coming from the features that are already adjusted for the map projection, but maybe I'm wrong. Here's my code:
var arclines = svg.append('g')
data_nested = d3.map(my_data)
var state_points = new Map();
var statesarc = topojson.feature(us, us.objects.states).features
statesarc.forEach(function(d) {
state_points.set(d.id, path.centroid(d))
})
arcdata = []
data_nested.values().forEach(function(d) {
arcline = {source: state_points.get(parseInt(d.Start_State_FIPS)), endpoint: state_points.get(parseInt(d.End_State_FIPS))}
arcdata.push(arcline)
})
arclines.selectAll("path")
.data(mydata)
.enter.append("path")
.attr('d', function(d) { return path({type: "LineString", coordinates: [d.source, d.endpoint]}) })