---code-annotations: hover---# Day 5: Analog Map {.unnumbered}<style>@importurl('https://fonts.googleapis.com/css2?family=Permanent+Marker&display=swap');svg.bad g:nth-child(2) {filter: drop-shadow(2px3px2px#ffffff33);}td, h2 {border: none;border-bottom: none!important;}figure h2 {padding-bottom: 0px;line-height: 1em;}figure h3 {margin-top: 0px;}</style>## Rabbit Ears 4EVAAnalog signals still abound in the good ol' US of A. "ChannelMaster" maintains a [list of broadcast towers](https://www.channelmaster.com/pages/tv-antenna-map-by-state) folks can use to best aim their antennae to get the best signal.I scraped that data (more on that below) and made a "hand-drawn" map with more than a little help from [Nicolas Lambert](https://observablehq.com/@neocartocnrs)'s [Cartographic Doodles](https://observablehq.com/@neocartocnrs/cartographic-doodles).It was cool to go back to some old-school D3 vs the modern/fancy Observable Plot.## Tech Used- R (scraping & data processing)- OJS in Quarto## Scraping Stations & TowersChannelMaster has a page for every ZIP code in every state. This is the [page for Maine](https://www.channelmaster.com/pages/tv-antenna-map-maine).If you hit the [page for my town](https://www.channelmaster.com/pages/tv-antenna-map-berwick-me-03901), you'll see a map with broadcast towers and a list of stations.Now, if you go there with Developer Tools open, you'll see there's a request that looks like this:```mdhttps://apps.channelmastertv.com/antenna-map-apple.php?{v=1213112p&address=&zip=03901&}```That's a tiny JSON file that looks like this:```{r}jsonlite::fromJSON("static/data/03901.json") |>str()```The tower coordinates are tucked away in that `towerData` string. We need to free-up that data.There are over 37K files, so we're going to use {furrr} to speed things up.```{r eval=FALSE}#| code-fold: falselibrary(future)library(furrr)library(rvest)library(stringi)library(data.table)plan(multisession)list.files( # <1> path = "~/projects/2023-30dmc/static/data/towers", # <1> pattern = "json$", # <1> full.names = TRUE # <1>) |> future_map( # <2> .progress = TRUE, # <3> \(.x) { .x <- jsonlite::fromJSON(.x) # <4> stri_split_fixed( # <5> str = .x$towerData, # <5> pattern = "," # <5> )[[1]][-1] |> # <5> stri_replace_all_regex( # <6> pattern = ":.*$", # <6> replacement = "" # <6> ) |> # <6> as.numeric() |> # <7> matrix( # <7> ncol = 2, # <7> byrow = TRUE # <7> ) |> # <7> as.data.frame() |> setNames( c("lat", "lng") ) |> within( location <- .x$locationname # <8> ) } ) |> rbindlist() -> towers # <9> towers[, c("lat", "lng")] |> # <10> unique() |> # <10> jsonlite::toJSON() |> writeLines( "~/projects/2023-30dmc/static/data/towers.json" )```1. Find all the tower data files.2. Easy parallelism for free!3. See progress.4. Read in the file and unserialize the JSON.5. Commas separate the values, so split them all out.6. Remove the janky station ids from the strings.7. Make a 2d numeric matrix out of the lat/lng values8. Include the location name if we ever want to do more with the data.9. Make it one big data frame.10. There are duplicates due to ZIP code overlap. We don't need multiple points as that would mean needlessly plotting over 200K elements.## "Hand" Drawn Maps In D3Nicolas incorporated [Rough.js](https://roughjs.com/) with Mike Bostock's [geoCurve](https://observablehq.com/@d3/context-to-curve) to create a JS function that can make pretty much any GeoJSON file look hand drawn-ish.I use it to make the base map, then plot the points on it.## Broadcast Tower Locations In The United StatesEach dot represents the location of an old-school televesion broadcast tower.```{ojs}{function sketch( geojson, { // GLOBAL PARAMETERS title, title_size = 32, title_fill = "#333333", paper = true, // paper background width = 700, // width of the map margin = 10, // margin projection = d3.geoIdentity().reflectY(true), k = 0.1, // level of simplification // FILL fill = "#346E9C", // fill color fillOpacity = 1, // fill opacity fillWeight = 0.5, // fill weight fillStyle = "zigzag", // fill style: hachure, solid, zigzag, cross-hatch, dots, dashed, or zigzag-line hachureGap = 2.5, // hachure gap roughness = 8, // roughness bowing = 0, // bowing overflow = true, // coloring overflow (boolean) // STROKE (2 overlapping lines) stroke = "#000", // stroke color strokeWidth1 = 1, // strokeWidth of the 1st line strokeWidth2 = 0.5, // strokeWidth of the 2nd line baseFrequency1 = 0.03, // baseFrequency (feTurbulence) of the 1st line baseFrequency2 = 0.06, // baseFrequency (feTurbulence) of the 2nd line scale1 = 5, // scale (feDisplacementMap) of the 1st line scale2 = 7, // scale (feDisplacementMap) of the 2ndline } = {}) { // simplify and merge let land = geo.simplify(geojson, { k: k, merge: true }); let delta = title ? 55 : 0; let adjust = paper ? [width / 12, 10, 20] : [0, 0, 0]; const [[x0, y0], [x1, y1]] = d3 .geoPath(projection.fitWidth(width - adjust[0] - margin * 2, land)) .bounds(land); let trans = projection.translate(); projection.translate([ trans[0] + adjust[0] + margin, trans[1] + adjust[1] + margin + delta, ]); let height = Math.ceil(y1 - y0) + adjust[2] + margin * 2 + delta; // start svg const svg = d3 .create("svg") .attr("viewBox", [0, 0, width, height]) .style("width", width) .style("height", height) .style("background", "white"); if (paper) { svg .append("g") .append("image") .attr("xlink:href", background) .attr("width", width) .attr("x", 0) .attr("y", 0); } // rough const rc = rough.svg(svg.node()); // svg filters (stroke) let defs = svg.append("defs"); const pencil1 = defs.append("filter").attr("id", "pencil1"); pencil1.append("feTurbulence").attr("baseFrequency", baseFrequency1); pencil1 .append("feDisplacementMap") .attr("in", "SourceGraphic") .attr("scale", scale1); const pencil2 = defs.append("filter").attr("id", "pencil2"); pencil2.append("feTurbulence").attr("baseFrequency", baseFrequency2); pencil2 .append("feDisplacementMap") .attr("in", "SourceGraphic") .attr("scale", scale2); // contour smoothing const path = smooth(d3.curveBasisClosed, projection); // land clip (for clipping) svg .append("clipPath") .attr("id", `myclip`) .append("path") .datum(land) .attr("d", path); // background pattern (fill) svg .append("g") .attr("opacity", fillOpacity) .attr("clip-path", overflow == true ? `none` : `url(#myclip)`) .node() .append( rc.path(path(land.features[0]), { stroke: "none", bowing: bowing, fill: fill, fillWeight: fillWeight, hachureGap: hachureGap, roughness: roughness, fillStyle: fillStyle, }) ); // contour patern (stroke) svg .append("path") .datum(land) .attr("d", path) .attr("fill", "none") .attr("stroke", stroke) .attr("stroke-width", strokeWidth1) .attr("filter", "url(#pencil1)"); svg .append("path") .datum(land) .attr("d", path) .attr("fill", "none") .attr("stroke", stroke) .attr("stroke-width", strokeWidth2) .attr("filter", "url(#pencil2)"); // Title svg .append("text") .attr("x", width / 2) .attr("y", 45) .attr("text-anchor", "middle") .style("font-family", "Permanent Marker") .attr("font-size", title_size) .attr("fill", title_fill) .text(title); return svg.node();} const s = sketch(usa, { title: "Rabbit Ears 4EVA", projection: albersUsa, width: width, fill: "#33ff66", paper: true, roughness: 10, k: 0.4, }); const computed = st.map((d) => albersUsa([d.lng, d.lat])); d3.select(s) .selectAll(".station") .data(computed) .enter() .append("circle", ".station") .attr("r", 1) .attr("opacity", 0.5) .attr("transform", function (d) { return "translate(" + d + ")"; }); return s;}albersUsa = d3.geoAlbersUsa();st = FileAttachment("static/data/towers.json").json();usa = ({ type: "FeatureCollection", name: "USA", features: world.features.filter((d) => d.properties.ISO3 === "USA"),});world = d3.json( "https://raw.githubusercontent.com/neocarto/bertin/main/data/world.geojson");background = FileAttachment("static/notebook.png").url();function geoCurvePath(curve, projection, context) { return (object) => { const pathContext = context === undefined ? d3.path() : context; d3.geoPath(projection, curveContext(curve(pathContext)))(object); return context === undefined ? pathContext + "" : undefined; };}function curveContext(curve) { return { moveTo(x, y) { curve.lineStart(); curve.point(x, y); }, lineTo(x, y) { curve.point(x, y); }, closePath() { curve.lineEnd(); }, };}function smooth(curve, projection, context) { return (object) => { const pathContext = context === undefined ? d3.path() : context; d3.geoPath(projection, curveContext(curve(pathContext)))(object); return context === undefined ? pathContext + "" : undefined; };}d3 = require("d3@7", "d3-geo-projection@4");geo = require("geotoolbox@1.7");rough = (await import("https://cdn.skypack.dev/roughjs")).default;```