Comment 28: This is a big, rather densely packed cell, so let’s break it down. The purpose of this cell is to construct the graduated symbol map via an SVG pane. It starts out by creating an svg element which then defines a “viewBox”, which creates a drawing surface for the map layers and legend. A title is also added toward the top of the SVG in order to tell things we’d want the audience to know; in my case “Black Population by Census Tract (%)”
The following section is where we build the legend, which will inherently show the meaning of the graduated class sizes and colors with their associated percentage ranges. This section of code effectively calculates the horizontal placement for each legend circle by looking at the radius for each circle as well as a locked gap space, makes sure that the circles don’t overlap in the legend. Remember those radius values we defined several cells below? This is where that defined information (color, radius) is coming into play. We also label each circle by putting the values to the right of each class via the circleLabelText command.
Next, the code constructs a mapGroup that maintains where the geographic base layer polygons are stored (which were used to create centroids way back in QGIS). All of the values in the census tracts are null, but that’s by design, as they’re merely there to serve as a place for the centroids to be put on top of, a visual reference for the betterment of our understanding. Similar to what we’ve done in previous assignments, the basemap polygons are then drawn via conversion of the TopoJSON tracts into a SVG path and then filling it with a gray color, as to not distract from the shades of purple used by the centroids. A second path uses the topojson.mesh() to then draw the boundaries, which I chose to be thin black lines, with a small stroke width of 0.3. due to the fact that census tracts are inherently a bit difficult to see at the state level, at least in larger cities where they become more compact to account for the population density change between that and the countryside.
Lastly, this is where we finally draw the graduated symbols, the focus of this entire project. Each of the point features are joined to a circle element, which are then sorted in a way that aligns with the progressive increase in the values of my classes (not necessarily the size of the classes of themselves, to be clear), so that they show up with large circles drawn first and then smaller ones appear on top of those. Basically, we don’t want the large circles blocking out the small ones, that’d be a huge issue in cities where the census tracts are, as mentioned, close together and smaller in size. Circles are then positioned in the center of each polygon (hence the name, centroids) and then sized using the already defined radius scale as well as the color codes grabbed from ColorBrewer. Similar to the basemap, the stroke and opacity settings are there in order to allow us the freedom to make the symbols easily visible and pleasing to the eye, and to enable larger graduated symbols (the higher value classes) to still be visible when overlapping, to an extent. Of course, there’s a title element as well that’s appended to each of the graduated symbols which allows a user to hover their cursor over them and see interactive displays pop up, which shows the identity of the tract (GEOID) on the left, and the percentage of population Black on the right.
Comment 27 [REUSED/MODIFIED FROM ASSIGNMENT 3]: These smaller cells focus on defining the width, height, and margin in order to establish the layout of the SVG canvas up above. These values control the size of the map and the spacing around it.
I'd like to 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, but I'm sort of struggling to make that happen, even after running some of the code through AI in order to get a second SVG (or map visualization?) to occur without interfering with the original map. Perhaps I need to retry with a different name for certain elements throughout this entire process, but I digress.
Comment 26 [REUSED/MODIFIED FROM ASSIGNMENT 3]: This cell forms a path which works in tandem with the projection cell. However, this path focuses on the points, or centroids, which will be used in the graduated symbol map shortly, and converts them into SVG string paths that will then draw the five-class graduated symbols (centroids) 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 centroid is being transformed using the same mathematical transformation.
Comment 25 [REUSED/MODIFIED FROM ASSIGNMENT 3]: In 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 tracts, in this map, are being used as the basemap, hence the name of this 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 in the basemap 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 above, in a few cells.
Comment 24 [REUSED/MODIFIED FROM ASSIGNMENT 3]: 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 one of the final step needed to prepare my TopoJSON file from the past few assignments to finally be visualized, before getting into the final cell to make the map word, which will require several block texts worth of explaining. 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 23: This cell serves as a format function that’s, as far as I know, used to consistently display the number of decimal places in number values throughout the entire notebook project. You see, by setting it to d3.format(“.1f”), the code rounds the number to whatever specified decimal place you used, which’ll then show up in breakpoints, legends, hover labels, etc. All values will follow the same enforced style.
Comment 22: This is the second of two visual variables to be used in making this graduated symbol map, performed via a function. This cell then creates a radius scale which will be used to determine circle size for each feature based on the value of the attribute in question. D3.scaleThreshold() then scales the class breakpoints with the corresponding symbol size specified in the radiusArray. Basically, the correct/correlating radius value will be returned a value falls within any given class of the 5 classes I’ve created. Think of the radius(##) cell above this cell as a check for that. As you can see, I put 35 in, and it returns 17, which is the 4th class, and 2nd largest. Basically, these two both act as a check and a determining factor when it comes to linking the chosen classification system to the visual symbology we’ve established here and in the color function cell. This will make sure that each and every census tract is drawn sing the appropriate symbol size and color, when combining the effect of these two function cells.
Comment 21 [REUSED/MODIFIED FROM ASSIGNMENT 3]: This is one of two visual variables being used in making this graduated symbol map, performed via a function. 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 have.
Comment 20: As far as I can tell, this cell appears to calculate the midpoint of each class interval by averaging the adjacent break values specified in breaksMinMax, which results in meanArray providing a set of values for each class. It’s a nice reference point we can return to when comparing our values to how they should be visually represented on the map.
Comment 19: These two cells define the radius of the circles for my graduated symbol map in two different ways. The first array (radiusArray) contains the final radius values I decided upon, which are intended to help with user perception in terms of differentiating classes from one another while sacrificing some “honesty” in regards to how proportionate the classes are relative to the size, which is going to be an inherent fact with any graduated symbol map given that each bin has a range, unlike proportional symbols which change their radius size with precision based on the variance in bins.
The second, larger call (radiusArrayMath) shows mathematically selected radius values that’ve been computed via the mean of each class then scaling the values relative to the largest class using a mathematical formula which employs scaling Ri = (Vi/Vl)^0.5 * Rl -- your dataset and attributes are what determines what goes in the numerator and denominator. This method is more “honest” than what I ended up choosing because the values provide proportional radius sizes, but this results in the smallest classes being little more than colored specs on the map, which aren’t super attractive for visualization. Effectively, the second cell (the one I’m commenting on right now) ended up guiding my decision making as far as class sizes go, more than anything.
Comment 18: You don’t really need to do anything with this cell, it just generates the text labels that end up being used to define the class intervals via the breakpoints found in breaksMinMax, which then formats them into ranges. Every pair of values increases from left to right, starting with “0.0 – 28.2”, “28.2 – 29.7”, etc. and then stores that in an array. These’ll be used in the legend to specify the ranges of values in each class.
Comment 17: As done in previous assignments, this is simply a series of HEX value colors retrieved from the ColorBrewer website. Take not that the number of colors match the number of classes we have, inherently. If you have one extra or one less, it may cause issues and the classes will not be displayed correctly on the final map. This cell makes it so that the color scheme we chose will be properly passed into the color scale later on in the map-making process. Each class will be matched perfectly with the HEX values listed here. They were chosen in such a way that sequentially increases the brightness/saturation as we move left to right.
Comment 16: This cell contains an array that not only tells us where our breakpoints are, but it also puts together the labels for the legend. It utilizes the minimum value contained in the attribute and then combines that with the class breakpoints that were generated in the cell we just dealt with in comment 15, as far as I can tell. This’ll come in handy later when we assign symbols/colors for visualizing the map.
Comment 15: In this cell, we select the number of class breaks that’ll end up being used to group the values of the chosen attribute (_pctBlack) for the eventual creation of the graduated symbol map. I chose 5 classes (bins, via "const k = 5;") for the data, as I believe it's not too few, nor too many. It's a reasonable, middle-of-the-road option to choose, and it allows us to see detail and variation between classes while not being too overwhelming to the point that we can't tell the difference between classes, visually speaking. As you can see, the code contains a number of choices for other classification schemes, including quantile and equal interval, to name a couple familiar ones. However, the only active one is (Jenks) natural breaks, as I felt that it’s the most suitable, easy-to-use choice given the extremely right skewed data that I’m dealing with here. If you recall from previous assignments, it uses ckmeans() to identify clusters in the data and then return the natural groupings of them via an algorithm, forcing the data to arrange itself into bins in such a way that emphasizes a bit more of a meaningful visual expression than say, quantiles or equal interval, which would not be a good fit for this data.
Comment 14: This cell creates an interactive histogram visualization that helps show the distribution of the mapped data/attribute of choice, that being _pctBlack, like the previous assignments for ease of use. As we know, the slider in the viewof bin cell controls the number of bins displayed and subsequently, the values and width of the bins, which are then used with Plot.binX() to calculate the how to divide up the data range on the x-axis. As far as I can tell, Plot.rectY() then determines the draw height for each of the bins on the y-axis.
Question: Why is it that, when I set this to 5 bins, only 4 appear on the histogram? Am I doing something wrong? This issue occurs throughout different number beyond just 5, so play around with it to see what I mean. Is this an issue with my data, or Observable?
Comment 12: This cell doesn’t really impact much, it’s mainly here to act as another check point in order to ensure you have your data and its attributes are working as intended.
In my case, it is giving me back the percentages of black individuals for each census tract in Iowa. It’s displaying the full array of joined point features.
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 [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 3 [REUSED FROM ASSIGNMENT 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 4: We import the polygon basemap in this cell. In my case, it’s census tracts for the state of Iowa, by simply loading in a TopoJSON file via the FileAttachment().json() command. As we’ve discussed, TopoJSON stores geographic boundaries in a compact format, which’ll require converting it into a GeoJSON in order to be drawn out on the map, I think. Now that we’ve imported it, calling upon it in the cell makes it active in this notebook. They’ll be projected, rendered, etc. underneath the graduated point symbols later on in this assignment. This basemap is what created the centroids for the graduated symbols in the first place via QGIS in the data preparation stage, so it makes sense that we’d want to use both elements where. While I am aware that census tracts, especially in cities, are often too small to see from the state-wide extent, we can always make inset maps or extreme zooms on specific cities in order to bring out some more intricate detail. Similar to choropleth mapping, this’ll bring out demographic data based on the geography of Iowa. The purpose of this step is the same regardless of your dataset or geographic extent.
Comment 5 [REUSED/MODIFIED 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, even though they've been fixed in my own following iterations of data used throughout these assignments.
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.
Comment 6: This cell loads in the point geometries that’ll be used in the graduated symbol map you saw up above. These are the centroids created via QGIS, as I stated previously. Utilizing FileAttachment().json(), we’re able to bring in the FeatureCollection for use in this notebook. These points are the spatial portion of the join that’ll take place in the next cell, as each of the features has a unique identifying code that will be matched with the corresponding attribute row and column from the TopoJSON (called tractsWattr.json in this assignment, established in Comment 4).. in this case, the GEOID is what I believe we’d call the primary key, or join code in layman’s terms. As an important aside, I completed the black population percentage calculations through excel, then joined the excel file with the 2020 tracts shapefile in QGIS, eliminating the need to perform those calculations within the notebook project this time around.
Comment 7: In this cell, we define the join key (common field we need to establish in order to proceed) that’s now going to join the GeoJSON geometries with the TopoJSON attributes (as mentioned before, the GEOID is our common attribute between each file’s respective table). Setting the idName = “GEOID” tells the notebook which attribute to search for inside of the GEOJSON feature and then matches that to the rows in the TopoJSON (((CHECK TO MAKE SURE THIS IS RIGHT))). This newly established shared ID between the two is what allows us to move forward with mapping the data, otherwise we wouldn’t be getting the proper, associated data when hovering over each centroid on the map.
Comment 8: This cell’s purpose is to specify the attribute that’ll be visualized via graduated symbols. In setting orgAttributeName = “_pctBlack”, the notebook is able to identify which column in the joined dataset has the percent Black population for each individual census tract. Effectively, this attribute ends up becoming the center focus for color modifications, legend building, symbology sizing, etc. Because we’re defining the attribute name here, we’re ensuring that any subsequent cells and their command lines reference this specific field.
Comment 9: This cell defines what would be the denominator used in the normalization process when creating derived variables within this observable notebook, in a similar fashion to what we did in Assignment 1. However, in this project, I intentionally left the field blank (nothing between the double quotation marks “”) due to the fact that _pctBlack is already normalized through excel before I joined it to the shapefile in QGIS in the data cleaning process before beginning with importing to this notebook. Regardless, you may be working with different data than I am, so it’s important that you know what this cell does in case you need it for your dataset and project.
Comment 10: This cell designates the final attribute name that’ll be used in any following symbolization processes, after making sure that normalization is applied, given the data structure—think of this as sort of conditional, in a way. It’s checking to make sure the normalization is applied/specified, and if it is, it appends the end of the name with the suffix “_std” like what was shown in the Assignment 4 Part 2 video as a way of indicating the attribute has been standardized within the project. When no normalization is used (within the project), the name will remain the same as what you last had it as. In my case, since I normalized the data in excel and joined in QGIS to the shapefile, my name stays as _pctBlack.
Comment 11: This optional cell contains a normalization step which will create a new, standardized attribute by dividing one variable by another and then dividing it by 100. The line of code runs through checks that see whether a normalizationAttribute’s been specified or not, and if one has, it then goes through each feature (graduated symbols and their associated, displayed attribute) and computes this new, normalized value, then containing it under the name finalAttributeName (or any other relevant name you give it). In my specific case, since I’ve already done all of the normalization off of Observable through excel, I intentionally left the area blank between the double quotation marks. This is an important step for datasets which have raw counts that need to be converted into percentages or ratios prior to mapping them out. It’s left here for you to use if you have a different type of dataset compared to my normalized census data.
Comment 13: This cell then extracts the attribute to be classified from each feature found in the GeoJSON file and then converts it into a numeric array. The expression Array.from(points.features, d => (+d.properties[finalAttributeName])) ends up going through all 896 census tracts and retrieves the value stored via the final attribute name chosen (in my case, _pctBlack). Adding + in front of the d.properties expression ensures that every value is turned into a number, which we need here as we don’t want strings. This’ll be used to help determine the range of the data as well as selecting a classification method for the final map (and building a radius scale, I think).