Rabbit Ears 4EVA

Analog signals still abound in the good ol’ US of A.

“ChannelMaster” maintains a list of broadcast towers 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’s 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 & Towers

ChannelMaster has a page for every ZIP code in every state. This is the page for Maine.

If you hit the page for my town, 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:

https://apps.channelmastertv.com/antenna-map-apple.php?{v=1213112p&address=&zip=03901&}

That’s a tiny JSON file that looks like this:

Code
jsonlite::fromJSON("static/data/03901.json") |> 
  str()
List of 8
 $ error       : chr ""
 $ stationcount: int 8
 $ towercount  : int 6
 $ towerData   : chr "1,43.175833,-71.2075:2,43.184444,-71.319444:3,43.416749,-70.804222:4,42.983694,-71.589777:5,42.302972,-71.21802"| __truncated__
 $ homeLat     : num 43.3
 $ homeLon     : num -70.9
 $ channelHTML : chr "<div class=\"one-tower-channel isDistant\" data-towerid=\"5\" data-strength=\"red\"><span>2.1</span><span class"| __truncated__
 $ locationname: chr "BERWICK, ME 03901"

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.

library(future)
library(furrr)
library(rvest)
library(stringi)
library(data.table)

plan(multisession)

list.files(
  path = "~/projects/2023-30dmc/static/data/towers",
  pattern = "json$",
  full.names = TRUE
) |>
  future_map(
    .progress = TRUE,
    \(.x) {
      .x <- jsonlite::fromJSON(.x)

      stri_split_fixed(
        str = .x$towerData,
        pattern = ","
      )[[1]][-1] |>
        stri_replace_all_regex(
          pattern = ":.*$",
          replacement = ""
        ) |>
        as.numeric() |>
        matrix(
          ncol = 2,
          byrow = TRUE
        ) |>
        as.data.frame() |>
        setNames(
          c("lat", "lng")
        ) |>
        within(
          location <- .x$locationname
        )
    }
  ) |>
  rbindlist() -> towers
  
  towers[, c("lat", "lng")] |>
    unique() |>
    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 values
8
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 D3

Nicolas incorporated Rough.js with Mike Bostock’s geoCurve 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 States

Each dot represents the location of an old-school televesion broadcast tower.

Code
{

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;

}
Code
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;