Comment 15: Below the main d3.scaleThreshold cell, there's smaller cells that focus on defining the width, height, and margin in order to establish the layout of the SVG canvas down below. These values control the size of the map and the spacing around it. I heavily adjusted the height and margin of my canvas in order to make the census tracts more visible since I couldn’t figure out how to get a proper inset map in this assignment.
Comment 1 [REUSED FROM ASSIGNMENT 1]: This cell simply connects the notebook to the d3.js library. This library is full of lots of visualizations that we can use in future assignments when we begin mapping. By typing in d3 = require("d3@5"), you make that connection. You honestly don’t even need to type it out traditionally, as there’s documentation online for ObservableHQ that has such command lines and their functions typed out already, ready for you to copy and paste.
Comment 2: Typing this short command out loads a small external module with pre-made functions that enable drawing color for legends. D3 doesn’t always have everything we need, so we import this legend module to help us later on in this assignment, as far as I can tell. For the time being, we’re just importing the legend, even though we aren’t using it in any of the above or below cells just yet. This part will come in handy when I type in legend() far later in this workflow.
Comment 3: This line defines an object that brings together several algorithms(?) together and effectively enables us to use natural breaks on our datasets later on in the workflow. You can leave this line as-is, but it's important to know what it's doing. If you want to edit the line, simply hover over the three vertical dots and click "pin" to see the cell with editable code. ckmeans is involved here, and if you recall from assignment 2, it’s just a calculation enabled via a number of statistical libraries that’re being summoned in this command. I may be accidentally using misnomers to define some of what’s in this line, but hopefully you’re able to follow the general idea as I have.
To be clear, even though we're importing all of these functions to start off the assignment and keep things tidy, there's nothing stopping you from moving these cells down to the bottom if you wish to keep them elsewhere. Cell locations on observableHQ are irrelevant in terms of functionality. If the cell is there and run, it will work anywhere.
Comment 9 [REUSED FROM ASSIGNMENT 1]: This cell maps the IDs to variables and/or attribute arrays. Effectively, this is how you join the attributes to the polygons. You may click the arrows to reveal the array of nested information regarding the IDs and what numbers are associated with them (raw counts first, for black populations in each tract, then black percentage in each tract immediately after that). I even gave it a title for the upcoming attributes, just to keep my ducks in a row so to speak. Let me show you: data = Object.assign(new Map(csv_data), {title: ["Percent Population of Iowa: African American per Census Tractt"]}) is a fairly intuitive command, you can see how the new map is going to use the csv_data that we imported and normalized in previous steps.
Comment 7 [REUSED FROM ASSIGNMENT 1]: This line transforms raw data into a feature collected to be used in the following assignments, all thanks to the topojson-library that we imported back in cell 3. My following explanation is a little long, but it contains warnings that you might want to know about.
After inserting the necessary text (in my case, tract2020_features = topojson.feature(tract2020, tract2020.objects.tract2020), but be warned, the name of your objects may be different than what your shapefile attributes show. For example, after my blunder in naming the TopoJSON “2020tracts” instead of “tract2020” (leading numbers do not work in ObservableHQ), I had to redo all of my data cleaning via QGIS and Mapshaper since the wrong name traced all the way back to the beginning, even before I changed the CRS to WGS84. I ended up having to redo all of my data because something deep inside one of the file format names would not change. Just a fair warning, be careful with this.
Once loaded in, you can use the drop down the arrow next to Object and see the giant nested structure of this cell area. There’s tons of arrows and various attributes, coordinates, and various other information they tell about your dataset. It’s easy to get lost, but follow each arrow slowly and sequentially, and it’ll start to make some sense.
Thankfully, my longitude and latitude coordinates did load in, even when the name wasn’t working. If you don’t change your .prj file to WGS84 before importing into ObservableHQ, this is where you’ll run into trouble. If it’s a 6 digit coordinate set, it’s possible that it’s UTM or some other projection that doesn’t work on web mapping. You must see expected coordinates (for instance, mine range in the lower -90 and lower 40 degrees, which is absolutely expected for Iowa. Use context clues to guide your problem-solving process. If you see a decimal point following two digits and then a handful of numbers after, chances are you imported your data correctly. Observable is officially able to read the TopoJSON at this point.
Comment 6 [REUSED FROM ASSIGNMENT 1]: Next, ObservableHQ allows you to upload files to manipulate on a web browser. On the righthand side of the page, there’s a list of vertically oriented icons, at the very top, there’s a paperclip icon. Click on this, and then simply click, or drag and drop your .csv and TopoJSON files into the notebook. Then, you must call upon your TopoJSON file attachment in this cell via the following command line: FileAttachment(“file_name_here”).json() ... This process turns it into a JavaScript object. Also, be sure not to have the name of your file or any of its attributes lead with numbers, I learned this the hard way. Now that you have it attached to the file into the notebook, you may access the files via commands like this and others, as will be demonstrated below.
This same cell is how you access the shapefile through observable, and you can click on the arrow to the left of the "Object" command line, which then allows you to observe the nested structure of the file, where you can find all of the attributes associated with, in my case, census tracts. However, this applies to any shapefile, be it counties, states, etc.
Underneath the nested structure, look to the left side at the purple-colored text which reads, "type" "arcs" "transform" "objects" which appear to be vertically oriented in a line. Under "objects" (yes, the lowercase one, not Object with a capital O), you'll notice the object collection. For me, it's named "tract2020," but for you, it will certainly have another name. Type this name in to the file attachment area, as initially mentioned above in the first paragraph. Under the array, you'll see the chain of various vertices that form the polygons in your shapefile.
Comment 17: However, with this cell, there’s no real reason to change the name or anything with it. It’s perfectly fine staying as-is. This cell forms a path which works in tandem with the projection cell. The path uses the geographic features (census tracts, in my case) and converts them into SVG string paths that will then draw the tract shapes on the screen in real-time, each time you open the Observable notebook. The line d3.geoPath().projection(projection) links the path to the projection that’s defined in the cell above this one. Every polygon is being transformed using the same mathematical transformation. The map would not work properly if we skipped this or the last steps. To succinctly put this into words: The projection helps define where things need to go, whereas the path generator takes that information and turns it into the visual elements we see on the screen below (with the added help of the next several functions and commands, which we’ll get to now).
Comment 16: This cell’s purpose is to define the map projection, which occurs through mathematical transformations that turn the coordinates into the space in the SVG canvas, as you’ll see below. The D3 library has a large number of projection types, and as you can see, the commented out lines show other options one might use, depending on the data they’re trying to display and what their intended purpose is. In my case, d3.geoMercator() works best, as Iowa doesn’t cover a huge area compared to other some other, larger states or regions. The distortion that exists is minimal and I felt as though this projection helped to showcase census tracts the most easily compared to some of the others. From what I’ve gathered .fitExtent() seems to automatically position and scale the map so that it fits inside of the margins, width, and height that we established in the previous cells and comments. This is just about the final step needed to prepare my TopoJSON file from the past few assignments to finally be visualized. You can change the name of this cell if you wish, based upon what you choose that’ll contextually fit with your selected projection.
Comment 12: This cell serves to define a color scheme using the D3 library’s d3.color() function, and each entry within the array represents a single color expressed in hexadecimal format, obtained from websites like ColorBrewer. Containing all five of these HEX codes within the d3.color() function converts the string into d3 color objects. From what I’ve gathered, d3 color objects will later allow us to pass into SVG commands/attributes(?) such as stroke, fill, etc. (For reference, Scalable Vector Graphics, or SVG, is a browser format that’s able to draw shapes using math instead of traditional user interfaces where you can draw it on your own with a pen tool, if that makes sense. It’s useful for rendering map polygons in web maps, but we’ll get to that in a bit more detail later).
As far as I can tell, due to the fact we’re storing this color scheme in an array, we’re able to use it again and again, and it can be applied consistently throughout the map *and* the legend alike. This helps ensure that the map and legend are showing the same color values for the same numeric attributes (in my case, pctBlack, as you’ll see in a bit. We’ll use this color scheme later via a scale functions and with various mapping functions. As of now, all this cell is doing is creating a foundation of ordered colors that’ll be used in the map later on.
Comment 13 [REUSED/MODIFIED FROM ASSIGNMENT 2]: The purpose of this cell is to compute the natural breaks (Jenks) classification via ckmeans(pctBlack, 5), which does the clustering to locate groups of values that help lower in-class variance while maximizing the differences between the classes. The 5 in (pctBlack, 5) specifies the number of classes. I chose 5 classes for every single classification scheme because that’s generally a fair number to use. It’s right in the middle of the road, not too few, not too many. 3 would've lost some detail, while 7 might've ended up being too many classes with how small some of these census tracts are - as in, it might've been hard to tell the visual differences between some of the classes.
Honestly, you don’t need to know a ton about this line of code regarding the ckmeans function, it’ll almost always be identical any time you want to make a Jenks classification scheme. Just know that using my dataset, pctBlack, this code then finds statistically meaningful places to distribute the data into the bins. This method can be useful for data that have uneven clustering, spacing, outliers, etc., because the algorithm/method tends to conform to the data you input instead of applying some arbitrary division, like quantile. Use on a case-by-case basis, as not all data may be wise to use with Jenks. In the case of this data and the map, I do think that Jenks is the right choice.
Comment 11 [REUSED/MODIFIED FROM ASSIGNMENT 2]: This code takes from the .csv file that I previously loaded in (csv_data) and then extracts specific columns of my choosing, done via the command: Array.from(csv_data.values(), => d[1]), which I believe looks through each object inside of my .csv dataset and then uses a mapping function (d => d[1]), which each correspond to a column that I’m trying to retrieve. 2 in this case is the third value I established in a prior cell, since 0 was the starting value there, 2 ends up acting as the third value here. The third value was the one derived from +Black/+Total to obtain the new attribute: pctBlack.
NEW COMMENT: Disclaimer: In assignment 2, I put the +Black/+Total (or pctBlack) attribute in the 3rd column, which would've required d => d[2] to map. However, for whatever reason, in this assignment I could not get it to map the 3rd column (it starts at 0, 1, and 2 ... [2] = 3rd column or whatever you'd like to call it). So, since it was correctly mapping the 2nd column [1], I just swapped my normalization to that area instead. Just figured it'd be worth pointing out. You may encounter this issue as well, this was the easiest fix in my opinion.
Comment 8 [REUSED FROM ASSIGNMENT 1]: In this cell, we're connecting the TopoJSON feature collection of polygons to the attributes listed in the .csv file. Mine is called "population.csv," but obviously, yours will be named something else, as will your attributes. The three I'm using here are GEOID (my join between the .csv and TopoJSON shapefile), Black population, and Total population for 2020 census tracts in Iowa. Add a + in front of the names of your attributes to convert it into a number in order to do math operations on it, but only in square brackets area. In order to do math operations, all attributes involved in an operation MUST have a + leading in front of the name with no spaces, like in my example ([[ ... +Black/+Total]]) All of this is done via the d3 library, as indicated toward the start with the "d3.csvParse command line. If run correctly, you'll see "csv_data = drop down arrow Array(n), n representing the number of polygons you have. In my case, there were 896 census tracts in Iowa in 2020. Each following array are individual polygons.
In addition, we create and normalize new variables when we use a formula on existing columns/attributes within my .csv file. In my case, I wanted a percentage of the black population per census tract in Iowa in 2020, so I divided the black population by the total population. We never want to map raw counts, so this step is crucial for anything to make sense following this step and in assignments 2 and 3, which we're doing right now; otherwise, larger census tracts with higher populations will always have higher raw counts, and wouldn’t meaningfully tell us anything about the population make up. This is how we compute a percentage. We can also divide population by area to calculate density, but that’s not what I decided to do with my data. Normalization adjusts the values so that all polygons are compared on the same scale, an even playing field, so to speak. We need our new attribute to reflect accurate rates in the upcoming web map, so this step is essential to get right in this assignment.
Comment 21: Likewise, this command tells us what RGB values correspond to the black population percentage of 0.0036, further contextualizing the map above.
Comment 19: Once again, this step technically isn't necessary, but it can help you determine if your data is properly joined or not. By typing data.get(your join attribute here), it then displays the percentage of the population that's black in this particular GEOID (19107080400), which is unique for each census tract. The percentage is shown above the cell with this command. Doing this again after creating the map with interactive hover-over values appearing makes this much easier to understand, in my opinion.
Comment 4: Here’s another function. This is an important one, so say, for example, if your data is an integer and you’d like to it to be formatted as a percentage on the interactive map and legend, this completes that task. It basically formats inputs and outputs it in a number of ways, depending on the data you’re dealing with and what you inserted into the format section. You may not need to fully understand this number formatting function just yet, but it’s worth knowing it exists.
Comment 5 [REUSED FROM ASSIGNMENT 1]: In this cell, we import the topojson-client library into our notebook assignment. This library is able to read TopoJSON files, so it must be loaded into ObservableHQ from in some of the first cells in our projects. As I mentioned, this is similar to cell 2, where both utilizes a require() function in order to load external libraries such as this, and because we loaded the library using this function, we now have the TopoJSON functions available to use within this particular notebook. Once again, you can expect to find this command on online documentation for ObservableHQ if you ever lose it.
Comment 14: What’s happening in this cell is relatively simple: The cell is defining the color scale that’ll end up being used to turn the numeric data I’ve normalized into color fills that’ll be applied and distributed across the shapefile I’ve cleaned up in the previous assignment(s). In this particular instance, we’re using d3.scaleThreshold(), which just assigns the colors we’ve chosen based on a set of breakpoints defined by the .domain(naturalbreaks) line, which creates the threshold boundaries. Statistically, I’m a bit perplexed by this line, as I’m not sure what helps with the calculation for the natural breaks, the domain here, or ckmeans? I think both are playing a part in this to some extent, but to what I’m not 100% sure. Moving along, the .range(orangehues) is what connects the ordered color scheme I chose from ColorBrewer that then corresponds to the ranges from the natural breaks. Domain and range combined, these two come together in one of the last fundamental cells and lines of code needed to create the map. We’ll see the dataset (or rather, pctBlack derived from the original datasets I downloaded from the Census Bureau) put into a visual format.
Due to the nature of the attribute I’m mapping (pctBlack), being so far right skewed, I decided that using this threshold scale of natural breaks made the most sense because it’d help make meaning out of the extremely unevenly spaced clusters (or lack of them, I’d argue!) It’s just going to make interpretation a whole lot easier than most other classification options would’ve, perhaps except quantile – that was my second choice.
Comment 10: This step technically isn't necessary, but it can help you determine if your data is properly joined or not. By typing data.get(your join attribute here), it then displays the percentage of the population that's black in this particular GEOID (19101090400), which is unique for each census tract. The percentage is shown above the cell with this command.
Comment 18: This is the big one. The purpose of this cell is to bring everything all together through the SVG canvas. The legend, drawing each of the geographic features (census tracts in Iowa) and data-related class color schemes all happens right here, visually. So, the SVG was started using .attr(viewBox) in order to make sure the map shows up correctly, entered in as canvas objects. This pulls from the width, height, and margin lines from earlier in the assignment. Whatever you “draw” first on this section will appear on top, so if I had points in addition to the polygons I’m using, I’d want to draw the points first in order for them to show up on top and not be covered.
Toward the top of this large cell, we generate the legend by using legend() and then inputting a number of commands. It, of course, adopts the same color scheme we established earlier on in this assignment. In order to change the location of the legend, I changed the y-coordinate to -10 to give more room for a larger map to show than what was demonstrated in the original forked assignment before I made any modifications. Everyone’s map, data, and intended purposes will be different, so it’s important that you get accustomed to what each number input, coordinate, and line of code in this area does in the grand scheme of things (for both the legend and the location of the geographic features). Again, by inserting the legend toward the top (or bottom, if you so choose), the viewer of the map instantly has an intuitive reference as to what each of the census tracts (polygons) represent.
The rest of the cell selects all of the tract features from my dataset, taking the geographic data and drawing it into polygons utilizing that path cell we defined just a little while back. There’s stroke styling, line joins, outline colors, and stroke width controls that change what the visual boundaries between tracts look like. Personally, with how small my census tracts appear on the map, even after making the map visually larger, I determined that making my stroke width just 0.4 was a necessary move in order to better bring out the detail of the tracts. Typically, I’d avoid going this small, but since you can still clearly see the boundary lines between the tracts, I figured it was a reasonable option to choose. The .fill color command then takes all of the tract GEOIDs (via the data dictionary we created earlier: .data.get(d.properties.GEOID)). This section is effectively how we’re joining the .csv to the TopoJSON file that I created for assignment 1) and passes them all through the color() scale, making the map show up with colors associated with the values that I’m interested in showing.
By doing this, I’m making sure that viewers of the map can hover over each tract (except, perhaps, ones that are so small within bigger urban areas like Cedar Rapids, Des Moines, or Davenport; an inset map would fix this issue) and see both the percentage of the black population it contains, as well as the associated color. The last main SVG-related topic worth covering in this line of code is .append(“title”) toward the end. This enables the map to return a value when hovering over each census tract polygon.