<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>tmap | Nikhil Kaza</title>
    <link>https://nkaza.github.io/category/tmap/</link>
      <atom:link href="https://nkaza.github.io/category/tmap/index.xml" rel="self" type="application/rss+xml" />
    <description>tmap</description>
    <generator>Wowchemy (https://wowchemy.com)</generator><language>en-us</language><copyright>© 2018-2025 Nikhil Kaza</copyright><lastBuildDate>Thu, 17 Sep 2026 00:00:00 +0000</lastBuildDate>
    <image>
      <url>https://nkaza.github.io/media/icon_hu1ca6a6912ef6c300619228a995d3f134_46128_512x512_fill_lanczos_center_3.png</url>
      <title>tmap</title>
      <link>https://nkaza.github.io/category/tmap/</link>
    </image>
    
    <item>
      <title>My Screed Against Legends</title>
      <link>https://nkaza.github.io/post/my-screed-against-legends/</link>
      <pubDate>Thu, 17 Sep 2026 00:00:00 +0000</pubDate>
      <guid>https://nkaza.github.io/post/my-screed-against-legends/</guid>
      <description>&lt;h2 id=&#34;introduction&#34;&gt;Introduction&lt;/h2&gt;
&lt;p&gt;A legend asks something unreasonable of the reader: stop looking at the data, find a small box somewhere else on the page, decode a colour or a symbol against a list of names, hold that mapping in memory, and go back to find the mark you were originally looking at. Do that for every line, every region, every category, on every chart you read.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;In most instances, this is unnecessary. A legend is an admission that the design failed to speak for itself.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This is not a new idea. Cleveland was making this argument about graphical perception back in the 1980s, working out how colour and position actually get decoded. Tufte&amp;rsquo;s case against chart junk amounts to the same argument against legends. Colours and symbols are comparatively poor at carrying categorical information on their own; position and text are much better.&lt;/p&gt;
&lt;p&gt;This post works through a series of visualisations, each built twice: legend version next to non-legend version. It&amp;rsquo;s not a rulebook, just ideas for cutting the cognitive burden on the reader. The point isn&amp;rsquo;t that legends are never justified; it is that they&amp;rsquo;re the default far more often than they should be.&lt;/p&gt;
&lt;h2 id=&#34;the-burden-of-legends&#34;&gt;The burden of legends&lt;/h2&gt;
&lt;p&gt;Legends often reflect authorial laziness. They&amp;rsquo;re easy to reach for because they let the author punt on resolving colour, symbol, text, and data conflicts within the visualisation itself, shoving everything into a white margin where no conflict can exist. That&amp;rsquo;s fine for quick exploratory work, where the point is just to spot patterns across many visualisations. It&amp;rsquo;s not fine when the point is to present a compelling case to a reader.&lt;/p&gt;
&lt;p&gt;Legends shift the cognitive burden from author to reader: a visual search for the right box in the key, a lookup, a repeated translation back to the mark on the chart. On a chart with three or four categories, that&amp;rsquo;s mildly annoying. On a chart with a dozen thin, similarly coloured lines, or a continuous colour scale map, the legend becomes the main obstacle to reading the chart at all. What visualisations are often about (the shape of the data, the outliers, and the general trend) gets lost in this translation.&lt;/p&gt;
&lt;h2 id=&#34;remedies&#34;&gt;Remedies&lt;/h2&gt;
&lt;p&gt;If not legends, then what? The remedy is direct visual integration made with deliberate and defensible choices: category names appended directly to the graph, text embedded strategically, colour-coded titles where the typography itself does the work of a key. Facets (small multiples) and tooltips help too. You can also draw the reader&amp;rsquo;s eye straight to the elements that matter, the way a photographer frames and focuses a shot to carry one message. Thinking explicitly about these choices cuts the cognitive friction. Removing legends doesn&amp;rsquo;t lower the bar for a visualisation; it raises it, forcing colour and symbol into much more deliberate use.&lt;/p&gt;
&lt;h3 id=&#34;direct-labels-should-be-default&#34;&gt;Direct labels should be default&lt;/h3&gt;
&lt;p&gt;Often, simply labelling the data is enough. Take median home sale price for five Texas markets, 2000&amp;ndash;2015, from &lt;code&gt;ggplot2::txhousing&lt;/code&gt;. Legend on the left, direct labels on the right:&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Show code&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;library(ggplot2)
library(dplyr)
library(ggrepel)
library(cowplot)
library(cols4all)

cities &amp;lt;- c(&amp;quot;Austin&amp;quot;, &amp;quot;Dallas&amp;quot;, &amp;quot;Houston&amp;quot;, &amp;quot;San Antonio&amp;quot;, &amp;quot;Fort Worth&amp;quot;)
d &amp;lt;- txhousing %&amp;gt;%
  filter(city %in% cities) %&amp;gt;%
  group_by(city, year) %&amp;gt;%
  summarise(median_price = mean(median, na.rm = TRUE), .groups = &amp;quot;drop&amp;quot;)

# One color per city, fixed here and reused wherever else in this post a city
# gets a color.

city_pal &amp;lt;- setNames(c4a(&amp;quot;misc.okabe&amp;quot;, length(cities)), sort(cities))
text_cols &amp;lt;- setNames(colorspace::darken(city_pal, amount = 0.45), names(city_pal))

base_theme &amp;lt;- theme_cowplot(font_size = 12) +
  background_grid(major = &amp;quot;xy&amp;quot;, minor = &amp;quot;none&amp;quot;)

p_legend &amp;lt;- ggplot(d, aes(year, median_price, color = city)) +
  geom_line(linewidth = 0.7) +
  scale_color_manual(values = city_pal) +
  scale_y_continuous(labels = scales::dollar_format(scale = 1e-3, suffix = &amp;quot;k&amp;quot;)) +
  labs(x = NULL, y = &amp;quot;Median sale price&amp;quot;, color = NULL, title = &amp;quot;With a legend&amp;quot;) +
  base_theme +
  theme(legend.position = &amp;quot;right&amp;quot;)

last_pts &amp;lt;- d %&amp;gt;%
  group_by(city) %&amp;gt;%
  filter(year == max(year)) %&amp;gt;%
  mutate(txt_col = text_cols[city])

p_direct &amp;lt;- ggplot(d, aes(year, median_price, color = city)) +
  geom_line(linewidth = 0.7, show.legend = FALSE) +
  geom_point(data = last_pts, size = 2, show.legend = FALSE) +
  geom_text_repel(
    data = last_pts, aes(label = city), color = last_pts$txt_col,
    hjust = 0, direction = &amp;quot;y&amp;quot;, xlim = c(max(d$year), NA),
    force = 1.5, box.padding = 0.3, min.segment.length = 0,
    segment.size = 0.3, segment.color = &amp;quot;grey60&amp;quot;, size = 3.2
  ) +
  scale_color_manual(values = city_pal) +
  scale_x_continuous(expand = expansion(mult = c(0.02, 0.22))) +
  scale_y_continuous(labels = scales::dollar_format(scale = 1e-3, suffix = &amp;quot;k&amp;quot;)) +
  labs(x = NULL, y = &amp;quot;Median sale price&amp;quot;, title = &amp;quot;With direct labels&amp;quot;) +
  base_theme

plot_grid(p_legend, p_direct, nrow = 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;img src=&#34;https://nkaza.github.io/post/my-screed-against-legends/index.en_files/figure-html/unnamed-chunk-1-1.png&#34; alt=&#34;&#34; width=&#34;960&#34; /&gt;
&lt;p&gt;Two details do the work in the right-hand panel. The labels are sorted by where the lines actually end, so they read top-to-bottom in the same order as the lines. Each label is tinted a darkened version of its own city&amp;rsquo;s colour rather than flat black, dark enough to stay legible, close enough to the line&amp;rsquo;s own hue that the label and the dot beside it read as one thing, not a caption the reader has to connect to a colour by hand.&lt;/p&gt;
&lt;p&gt;Now it&amp;rsquo;s worth asking: is the colour even needed here? Probably not.&lt;/p&gt;
&lt;h3 id=&#34;a-map-is-no-different&#34;&gt;A map is no different&lt;/h3&gt;
&lt;p&gt;A map is no different than any other 2D statistical graphic, where XY are locations rather than other variables, such as time and prices. If Texas&amp;rsquo;s counties are divided into regions, a categorical map showing where each region sits and how big it is doesn&amp;rsquo;t really need a legend. &lt;code&gt;tmap&lt;/code&gt; defaults reach for one just as readily as &lt;code&gt;ggplot2&lt;/code&gt;&amp;rsquo;s do. And, we must resist it.&lt;/p&gt;
&lt;p&gt;We can use the same direct-labelling trick as the charts above. Put the label directly where it matters and the cognitive burden disappears: the right-hand map reads instantly. Nobody has to check which colour means &amp;ldquo;West Texas&amp;rdquo;. The map just says so, over the actual West Texas counties.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Show code&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;library(sf)
library(tigris)
library(tmap)

# label_ink() picks whichever of ink or white clears more WCAG contrast
# against a fill --- needed here, and again later, wherever a label sits
# directly inside a solid-colored region
rel_lum &amp;lt;- function(hex) {
  rgb &amp;lt;- grDevices::col2rgb(hex) / 255
  rgb_lin &amp;lt;- ifelse(rgb &amp;lt;= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055)^2.4)
  drop(0.2126 * rgb_lin[1, ] + 0.7152 * rgb_lin[2, ] + 0.0722 * rgb_lin[3, ])
}
contrast_ratio &amp;lt;- function(h1, h2) {
  l &amp;lt;- cbind(rel_lum(h1), rel_lum(h2))
  hi &amp;lt;- pmax(l[, 1], l[, 2]); lo &amp;lt;- pmin(l[, 1], l[, 2])
  (hi + 0.05) / (lo + 0.05)
}
label_ink &amp;lt;- function(fill_hex, ink = &amp;quot;black&amp;quot;, white = &amp;quot;white&amp;quot;) {
  ifelse(contrast_ratio(fill_hex, ink) &amp;gt;= contrast_ratio(fill_hex, white), ink, white)
}

# Texas cities grouped into six regions, each with its 2015 median home
# price attached --- built once here and reused throughout the rest of this
# post wherever a Texas market needs a location or a price
regions &amp;lt;- list(
  &amp;quot;North Texas&amp;quot; = c(&amp;quot;Dallas&amp;quot;, &amp;quot;Fort Worth&amp;quot;, &amp;quot;Arlington&amp;quot;, &amp;quot;Irving&amp;quot;, &amp;quot;Garland&amp;quot;),
  &amp;quot;Gulf Coast&amp;quot; = c(&amp;quot;Houston&amp;quot;, &amp;quot;Galveston&amp;quot;, &amp;quot;Beaumont&amp;quot;, &amp;quot;Port Arthur&amp;quot;, &amp;quot;Victoria&amp;quot;),
  &amp;quot;Central Texas&amp;quot; = c(&amp;quot;Austin&amp;quot;, &amp;quot;San Antonio&amp;quot;, &amp;quot;San Marcos&amp;quot;, &amp;quot;Waco&amp;quot;, &amp;quot;Kerrville&amp;quot;),
  &amp;quot;West Texas&amp;quot; = c(&amp;quot;Amarillo&amp;quot;, &amp;quot;Lubbock&amp;quot;, &amp;quot;Midland&amp;quot;, &amp;quot;Odessa&amp;quot;, &amp;quot;San Angelo&amp;quot;, &amp;quot;Abilene&amp;quot;, &amp;quot;El Paso&amp;quot;, &amp;quot;Wichita Falls&amp;quot;),
  &amp;quot;Rio Grande Valley&amp;quot; = c(&amp;quot;Laredo&amp;quot;, &amp;quot;McAllen&amp;quot;, &amp;quot;Harlingen&amp;quot;, &amp;quot;Brownsville&amp;quot;, &amp;quot;South Padre Island&amp;quot;, &amp;quot;Corpus Christi&amp;quot;),
  &amp;quot;East Texas&amp;quot; = c(&amp;quot;Tyler&amp;quot;, &amp;quot;Lufkin&amp;quot;, &amp;quot;Nacogdoches&amp;quot;, &amp;quot;Paris&amp;quot;, &amp;quot;Texarkana&amp;quot;)
)
region_df &amp;lt;- stack(regions) %&amp;gt;%
  rename(city = values, region = ind) %&amp;gt;%
  mutate(city = as.character(city), region = as.character(region))

tx_places &amp;lt;- places(state = &amp;quot;TX&amp;quot;, cb = TRUE, year = 2022, progress_bar = FALSE) %&amp;gt;%
  mutate(base_name = gsub(&amp;quot; city$| CDP$| town$&amp;quot;, &amp;quot;&amp;quot;, NAME)) %&amp;gt;%
  filter(base_name %in% region_df$city)
pts &amp;lt;- st_centroid(tx_places) %&amp;gt;% st_transform(4326)
pts_coords &amp;lt;- st_coordinates(pts)
pts_df &amp;lt;- pts %&amp;gt;% st_drop_geometry() %&amp;gt;% mutate(lon = pts_coords[, 1], lat = pts_coords[, 2]) %&amp;gt;%
  select(city = base_name, lon, lat)

price2015 &amp;lt;- txhousing %&amp;gt;% filter(city %in% region_df$city, year == 2015) %&amp;gt;%
  group_by(city) %&amp;gt;% summarise(price2015 = mean(median, na.rm = TRUE))

core &amp;lt;- region_df %&amp;gt;% left_join(pts_df, by = &amp;quot;city&amp;quot;) %&amp;gt;% left_join(price2015, by = &amp;quot;city&amp;quot;)

tx_counties &amp;lt;- counties(state = &amp;quot;TX&amp;quot;, cb = TRUE, year = 2022, progress_bar = FALSE) %&amp;gt;%
  st_transform(4326)
county_pts &amp;lt;- st_coordinates(st_centroid(tx_counties))
city_pts &amp;lt;- as.matrix(core[, c(&amp;quot;lon&amp;quot;, &amp;quot;lat&amp;quot;)])
nearest_idx &amp;lt;- sapply(seq_len(nrow(county_pts)), function(i) {
  d &amp;lt;- sqrt((city_pts[, 1] - county_pts[i, 1])^2 + (city_pts[, 2] - county_pts[i, 2])^2)
  which.min(d)
})
tx_counties$region &amp;lt;- core$region[nearest_idx]
tx_counties$region_wrapped &amp;lt;- gsub(&amp;quot; &amp;quot;, &amp;quot;\n&amp;quot;, tx_counties$region)

region_order &amp;lt;- sort(unique(tx_counties$region))
region_cols &amp;lt;- setNames(c4a(&amp;quot;misc.okabe&amp;quot;, length(region_order)), region_order)
region_txt  &amp;lt;- label_ink(region_cols)

m_legend &amp;lt;- tm_shape(tx_counties) +
  tm_polygons(fill = &amp;quot;region&amp;quot;, fill.scale = tm_scale_categorical(values = &amp;quot;misc.okabe&amp;quot;),
              col = &amp;quot;white&amp;quot;, lwd = 0.2, fill.legend = tm_legend(title = &amp;quot;Region&amp;quot;)) +
  tm_layout(frame = FALSE) + tm_title(&amp;quot;With a legend&amp;quot;, fontface = &amp;quot;bold&amp;quot;)

region_diss &amp;lt;- tx_counties %&amp;gt;% group_by(region, region_wrapped) %&amp;gt;%
  summarise(geometry = st_union(geometry), .groups = &amp;quot;drop&amp;quot;) %&amp;gt;% st_centroid()

m_direct &amp;lt;- tm_shape(tx_counties) +
  tm_polygons(fill = &amp;quot;region&amp;quot;, fill.scale = tm_scale_categorical(values = &amp;quot;misc.okabe&amp;quot;),
              col = &amp;quot;white&amp;quot;, lwd = 0.2, fill.legend = tm_legend(show = FALSE)) +
  tm_layout(frame = FALSE) + tm_title(&amp;quot;With direct labels&amp;quot;, fontface = &amp;quot;bold&amp;quot;)

for (r in region_order) {
  rc &amp;lt;- filter(region_diss, region == r)
  m_direct &amp;lt;- m_direct + tm_shape(rc) +
    tm_text(&amp;quot;region_wrapped&amp;quot;, size = 0.8, fontface = &amp;quot;bold&amp;quot;, col = region_txt[[r]])
}

tmap_arrange(m_legend, m_direct, ncol = 2)
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;img src=&#34;https://nkaza.github.io/post/my-screed-against-legends/index.en_files/figure-html/unnamed-chunk-2-1.png&#34; alt=&#34;&#34; width=&#34;960&#34; /&gt;
&lt;h3 id=&#34;draw-attention-to-what-matters&#34;&gt;Draw attention to what matters&lt;/h3&gt;
&lt;p&gt;Stacked bars are a common way to show a composition changing between two points in time. Total height across two bars is trivial to compare; one segment&amp;rsquo;s height across two bars is not, because every segment except the bottom one sits on a different, shifting baseline. And a legend is needed just to know which colour is which category before any comparison can even start. A slope graph fixes most of these issues.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Show code&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;cities &amp;lt;- c(&amp;quot;Austin&amp;quot;, &amp;quot;Dallas&amp;quot;, &amp;quot;Houston&amp;quot;, &amp;quot;San Antonio&amp;quot;, &amp;quot;Fort Worth&amp;quot;)
sales2 &amp;lt;- txhousing %&amp;gt;%
  filter(city %in% cities, year %in% c(2000, 2015)) %&amp;gt;%
  group_by(city, year) %&amp;gt;%
  summarise(sales = sum(sales, na.rm = TRUE), .groups = &amp;quot;drop&amp;quot;)

# city_pal and its darkened text_cols companion were both defined by the
# first chart; reused as-is here
sales2 &amp;lt;- sales2 %&amp;gt;% mutate(line_col = city_pal[city])

p_legend_bar &amp;lt;- ggplot(sales2, aes(factor(year), sales, fill = city)) +
  geom_col(width = 0.6) +
  scale_fill_manual(values = city_pal) +
  scale_y_continuous(labels = scales::label_number(scale = 1e-3, suffix = &amp;quot;k&amp;quot;)) +
  labs(x = NULL, y = &amp;quot;Home sales&amp;quot;, fill = NULL, title = &amp;quot;With a legend&amp;quot;) +
  base_theme +
  theme(legend.position = &amp;quot;right&amp;quot;, legend.text = element_text(size = 8))

lab2 &amp;lt;- sales2 %&amp;gt;%
  mutate(lab = paste0(city, &amp;quot;  &amp;quot;, scales::label_number(scale = 1e-3, accuracy = 0.1, suffix = &amp;quot;k&amp;quot;)(sales)),
         txt_col = text_cols[city],
         label_x = if_else(year == 2000, 1998, 2017))

p_slope &amp;lt;- ggplot(sales2, aes(year, sales, color = line_col, group = city)) +
  geom_line(linewidth = 0.7, show.legend = FALSE) +
  geom_point(size = 2, show.legend = FALSE) +
  geom_text_repel(data = filter(lab2, year == 2000), aes(label_x, sales, label = lab, color = txt_col),
                   hjust = 1, direction = &amp;quot;y&amp;quot;, xlim = c(NA, 1998),
                   force = 2, box.padding = 0.2, min.segment.length = 0,
                   segment.size = 0.3, segment.color = &amp;quot;grey60&amp;quot;, size = 3, show.legend = FALSE) +
  geom_text_repel(data = filter(lab2, year == 2015), aes(label_x, sales, label = lab, color = txt_col),
                   hjust = 0, direction = &amp;quot;y&amp;quot;, xlim = c(2017, NA),
                   force = 2, box.padding = 0.2, min.segment.length = 0,
                   segment.size = 0.3, segment.color = &amp;quot;grey60&amp;quot;, size = 3, show.legend = FALSE) +
  scale_color_identity() +
  scale_x_continuous(breaks = c(2000, 2015), expand = expansion(mult = c(0.55, 0.55))) +
  scale_y_continuous(labels = scales::label_number(scale = 1e-3, suffix = &amp;quot;k&amp;quot;)) +
  labs(x = NULL, y = &amp;quot;Home sales&amp;quot;, title = &amp;quot;With direct labels (a slope graph)&amp;quot;) +
  base_theme

plot_grid(p_legend_bar, p_slope, nrow = 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;img src=&#34;https://nkaza.github.io/post/my-screed-against-legends/index.en_files/figure-html/unnamed-chunk-3-1.png&#34; alt=&#34;&#34; width=&#34;960&#34; /&gt;
&lt;p&gt;Slope graphs, a Tufte invention, let you focus on how individual components change by leaning on positional shift instead of colour. Because 2000 and 2015 are visually connected by a line, its angle tells the reader both the strength and direction of change directly. Colour becomes largely unnecessary, and so does the legend.&lt;/p&gt;
&lt;h3 id=&#34;group-with-small-multiples&#34;&gt;Group with small multiples&lt;/h3&gt;
&lt;p&gt;Five cities is a comfortable case for colours and direct labels. Nine is past the point where colour stops working and the labels collide, ruling out a legible chart either way. &lt;code&gt;cols4all&lt;/code&gt; makes that failure concrete: asking &lt;code&gt;misc.okabe&lt;/code&gt; for a ninth colour throws a fit and stops. So the visualisation falls back to &lt;code&gt;ggplot2&lt;/code&gt;&amp;rsquo;s unvalidated default hue wheel. Direct labels fail the opposite way: nine lines converging on the right edge collide no matter how hard &lt;code&gt;ggrepel&lt;/code&gt; works.&lt;/p&gt;
&lt;p&gt;Small multiples let us make sense of patterns within and across groups. By 2015, Austin, Dallas, Houston, and San Antonio are all above $190k, and the next city down, Arlington, trails by twenty thousand dollars. That gap splits the nine markets cleanly: four large markets, five smaller ones. Any other sensible, deliberate faceting would work just as well.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Show code&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;cities9 &amp;lt;- c(&amp;quot;Austin&amp;quot;, &amp;quot;Dallas&amp;quot;, &amp;quot;Houston&amp;quot;, &amp;quot;San Antonio&amp;quot;, &amp;quot;Fort Worth&amp;quot;,
             &amp;quot;El Paso&amp;quot;, &amp;quot;Arlington&amp;quot;, &amp;quot;Tyler&amp;quot;, &amp;quot;Beaumont&amp;quot;)
top4 &amp;lt;- c(&amp;quot;Austin&amp;quot;, &amp;quot;Dallas&amp;quot;, &amp;quot;Houston&amp;quot;, &amp;quot;San Antonio&amp;quot;)

d9raw &amp;lt;- txhousing %&amp;gt;%
  filter(city %in% cities9, year %in% 2000:2015) %&amp;gt;%
  group_by(city, year) %&amp;gt;%
  summarise(median_price = mean(median, na.rm = TRUE), .groups = &amp;quot;drop&amp;quot;)

order9 &amp;lt;- d9raw %&amp;gt;% filter(year == max(year)) %&amp;gt;% arrange(desc(median_price)) %&amp;gt;% pull(city)
d9legend &amp;lt;- d9raw %&amp;gt;% mutate(city = factor(city, levels = order9))

p_legend9 &amp;lt;- ggplot(d9legend, aes(year, median_price, color = city)) +
  geom_line(linewidth = 0.7) +
  scale_y_continuous(labels = scales::dollar_format(scale = 1e-3, suffix = &amp;quot;k&amp;quot;)) +
  labs(x = NULL, y = &amp;quot;Median sale price&amp;quot;, color = NULL, title = &amp;quot;With a legend (nine series, unvalidated colours)&amp;quot;) +
  base_theme +
  theme(legend.position = &amp;quot;right&amp;quot;,
        legend.text = element_text(size = 8),
        plot.title = element_text(size = 10))

d9 &amp;lt;- d9raw %&amp;gt;%
  mutate(group = factor(if_else(city %in% top4, &amp;quot;The four largest metros&amp;quot;, &amp;quot;Five smaller metros&amp;quot;),
                         levels = c(&amp;quot;The four largest metros&amp;quot;, &amp;quot;Five smaller metros&amp;quot;)))
order_lvls &amp;lt;- d9 %&amp;gt;% filter(year == max(year)) %&amp;gt;% arrange(group, desc(median_price)) %&amp;gt;% pull(city)
d9 &amp;lt;- d9 %&amp;gt;% mutate(city = factor(city, levels = order_lvls))
pal9 &amp;lt;- setNames(rep(c4a(&amp;quot;misc.okabe&amp;quot;, 5), length.out = 9), order_lvls)
text_cols9 &amp;lt;- setNames(colorspace::darken(pal9, amount = 0.45), names(pal9))
last_pts9 &amp;lt;- d9 %&amp;gt;% group_by(city) %&amp;gt;% filter(year == max(year)) %&amp;gt;% mutate(txt_col = text_cols9[city])

p_direct9 &amp;lt;- ggplot(d9, aes(year, median_price, color = city)) +
  geom_line(linewidth = 0.7, show.legend = FALSE) +
  geom_point(data = last_pts9, size = 1.6, show.legend = FALSE) +
  geom_text_repel(data = last_pts9, aes(label = city), color = last_pts9$txt_col,
                   hjust = 0, direction = &amp;quot;y&amp;quot;, xlim = c(2015, NA),
                   force = 15, box.padding = 0.5, min.segment.length = 0,
                   segment.size = 0.3, segment.color = &amp;quot;grey60&amp;quot;, size = 2.7,
                   max.overlaps = Inf, show.legend = FALSE) +
  facet_wrap(~group, ncol = 2) +
  scale_color_manual(values = pal9) +
  scale_x_continuous(breaks = c(2000, 2005, 2010, 2015), expand = expansion(mult = c(0.02, 0.3))) +
  scale_y_continuous(labels = scales::dollar_format(scale = 1e-3, suffix = &amp;quot;k&amp;quot;)) +
  labs(x = NULL, y = &amp;quot;Median sale price&amp;quot;, title = &amp;quot;With direct labels, grouped by tier&amp;quot;) +
  base_theme +
  theme(strip.text = element_text(face = &amp;quot;bold&amp;quot;, hjust = 0, size = 11),
        strip.background = element_blank(),
        panel.spacing = unit(1.3, &amp;quot;lines&amp;quot;),
        plot.title = element_text(size = 10))

plot_grid(p_legend9, p_direct9, nrow = 2)
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;img src=&#34;https://nkaza.github.io/post/my-screed-against-legends/index.en_files/figure-html/unnamed-chunk-4-1.png&#34; alt=&#34;&#34; width=&#34;912&#34; /&gt;
&lt;p&gt;Each panel does the same job the very first chart in this post did, so comparison &lt;em&gt;within&lt;/em&gt; a group still works exactly like the five-city version. What faceting adds is comparison &lt;em&gt;across&lt;/em&gt; groups: the two panels share a y-axis (the &lt;code&gt;facet_wrap()&lt;/code&gt; default, not overridden with &lt;code&gt;scales = &amp;quot;free_y&amp;quot;&lt;/code&gt;), so the gap between the tiers stays visible as a gap. And because each panel is self-contained, you can reuse colours across facets, or drop them altogether.&lt;/p&gt;
&lt;h3 id=&#34;highlight-the-one-series-that-matters&#34;&gt;Highlight the one series that matters&lt;/h3&gt;
&lt;p&gt;Faceting solves &amp;ldquo;too many series&amp;rdquo; when every series matters roughly equally. Often it doesn&amp;rsquo;t: the real story is one or two of them, and the rest exist only to give that story a sense of scale. Sometimes a general trend matters; other times a genuine outlier is the star. Either way, most of the data is background to the few that need to shine. Think of it as the f-stop on a camera, separating foreground from background.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;txhousing&lt;/code&gt; has 24 Texas cities with complete price data across 2000&amp;ndash;2015, too many for a legend to be any use. All 24, distinctly coloured and keyed:&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Show code&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;complete_cities &amp;lt;- txhousing %&amp;gt;%
  filter(year %in% 2000:2015) %&amp;gt;%
  group_by(city) %&amp;gt;%
  summarise(n_na = sum(is.na(median))) %&amp;gt;%
  filter(n_na == 0) %&amp;gt;%
  pull(city)

d24 &amp;lt;- txhousing %&amp;gt;%
  filter(city %in% complete_cities, year %in% 2000:2015) %&amp;gt;%
  group_by(city, year) %&amp;gt;%
  summarise(median_price = mean(median, na.rm = TRUE), .groups = &amp;quot;drop&amp;quot;)

p_legend24 &amp;lt;- ggplot(d24, aes(year, median_price, color = city)) +
  geom_line(linewidth = 0.6) +
  scale_y_continuous(labels = scales::dollar_format(scale = 1e-3, suffix = &amp;quot;k&amp;quot;)) +
  labs(x = NULL, y = &amp;quot;Median sale price&amp;quot;, color = NULL, title = &amp;quot;With a legend (24 series)&amp;quot;) +
  base_theme +
  theme(legend.position = &amp;quot;right&amp;quot;, legend.text = element_text(size = 6),
        legend.key.height = unit(0.7, &amp;quot;lines&amp;quot;))

highlight &amp;lt;- c(fastest = &amp;quot;Irving&amp;quot;, typical = &amp;quot;Amarillo&amp;quot;)
pal2 &amp;lt;- setNames(c4a(&amp;quot;misc.okabe&amp;quot;, 2), c(&amp;quot;fastest&amp;quot;, &amp;quot;typical&amp;quot;))
text_cols2 &amp;lt;- colorspace::darken(pal2, amount = 0.45)

d24h &amp;lt;- d24 %&amp;gt;%
  mutate(hl = case_when(city == highlight[&amp;quot;fastest&amp;quot;] ~ &amp;quot;fastest&amp;quot;,
                         city == highlight[&amp;quot;typical&amp;quot;] ~ &amp;quot;typical&amp;quot;,
                         TRUE ~ &amp;quot;other&amp;quot;))
last24 &amp;lt;- d24h %&amp;gt;%
  filter(hl != &amp;quot;other&amp;quot;, year == max(year)) %&amp;gt;%
  mutate(lab = ifelse(hl == &amp;quot;fastest&amp;quot;, paste0(city, &amp;quot;  (fastest growth)&amp;quot;), paste0(city, &amp;quot;  (typical)&amp;quot;)),
         txt_col = text_cols2[hl])

p_highlight &amp;lt;- ggplot() +
  geom_line(data = filter(d24h, hl == &amp;quot;other&amp;quot;), aes(year, median_price, group = city),
            color = &amp;quot;grey80&amp;quot;, linewidth = 0.5) +
  geom_line(data = filter(d24h, hl != &amp;quot;other&amp;quot;), aes(year, median_price, color = hl, group = city),
            linewidth = 1) +
  geom_point(data = last24, aes(year, median_price, color = hl), size = 2, show.legend = FALSE) +
  geom_text_repel(data = last24, aes(year, median_price, label = lab), color = last24$txt_col,
                   hjust = 0, direction = &amp;quot;y&amp;quot;, xlim = c(max(d24$year), NA),
                   force = 2, box.padding = 0.3, min.segment.length = 0,
                   segment.size = 0.3, segment.color = &amp;quot;grey60&amp;quot;, size = 3.2) +
  scale_color_manual(values = c(fastest = pal2[[&amp;quot;fastest&amp;quot;]], typical = pal2[[&amp;quot;typical&amp;quot;]]), guide = &amp;quot;none&amp;quot;) +
  scale_x_continuous(breaks = c(2000, 2005, 2010, 2015), expand = expansion(mult = c(0.02, 0.55))) +
  scale_y_continuous(labels = scales::dollar_format(scale = 1e-3, suffix = &amp;quot;k&amp;quot;)) +
  labs(x = NULL, y = &amp;quot;Median sale price&amp;quot;, title = &amp;quot;With two series highlighted and labelled&amp;quot;) +
  base_theme +
  theme(plot.margin = margin(5.5, 15, 5.5, 5.5))

plot_grid(p_legend24, p_highlight, nrow = 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;img src=&#34;https://nkaza.github.io/post/my-screed-against-legends/index.en_files/figure-html/unnamed-chunk-5-1.png&#34; alt=&#34;&#34; width=&#34;960&#34; /&gt;
&lt;p&gt;A twenty-four-row legend for twenty-four lines is pointless. &lt;code&gt;cols4all&lt;/code&gt; refuses outright past eight colours, and even &lt;code&gt;ggplot2&lt;/code&gt;&amp;rsquo;s fallback rainbow just produces a wall of near-identical hues matched to a key nobody&amp;rsquo;s going to scroll through. Most of those lines aren&amp;rsquo;t the story anyway: they&amp;rsquo;re there to show that prices broadly rose across Texas over fifteen years, which the grey backdrop already says perfectly well as a shape. What actually needed an identity was which city rose fastest (Irving) and which one stayed closest to typical (Amarillo). So those are the only two drawn in colour, the only two labelled, and the only two a reader is asked to remember. If your text is about these two, leave the rest in the background.&lt;/p&gt;
&lt;h3 id=&#34;label-the-regions-that-matter&#34;&gt;Label the regions that matter&lt;/h3&gt;
&lt;p&gt;Maps make the case even more directly, because the colour-to-value mapping in a continuous legend is genuinely hard to read precisely. Nobody can look at a shade of blue and report a city&amp;rsquo;s price from a colour bar.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Show code&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;
tx_outline &amp;lt;- states(cb = TRUE, resolution = &amp;quot;20m&amp;quot;, year = 2022, progress_bar = FALSE) %&amp;gt;%
  filter(STUSPS == &amp;quot;TX&amp;quot;) %&amp;gt;% st_transform(4326)

seq_blue &amp;lt;- rev(c4a(&amp;quot;hcl.blues3&amp;quot;, 13))
pal_fn &amp;lt;- scales::gradient_n_pal(seq_blue)
core &amp;lt;- core %&amp;gt;% mutate(fill_hex = pal_fn((price2015 - min(price2015)) / diff(range(price2015))))

p_legend_map &amp;lt;- ggplot() +
  geom_sf(data = tx_outline, fill = &amp;quot;grey96&amp;quot;, color = &amp;quot;grey80&amp;quot;, linewidth = 0.3) +
  geom_point(data = core, aes(lon, lat, color = price2015), size = 2.6) +
  scale_color_gradientn(colors = seq_blue, labels = scales::dollar_format(scale = 1e-3, suffix = &amp;quot;k&amp;quot;),
                         name = &amp;quot;2015 median\nprice&amp;quot;) +
  theme_void(base_size = 12) +
  theme(plot.title = element_text(face = &amp;quot;bold&amp;quot;)) +
  labs(title = &amp;quot;With a legend&amp;quot;)

top3 &amp;lt;- core %&amp;gt;% slice_max(price2015, n = 3)
bottom3 &amp;lt;- core %&amp;gt;% slice_min(price2015, n = 3)
core &amp;lt;- core %&amp;gt;%
  mutate(is_extreme = city %in% c(top3$city, bottom3$city),
         lab = paste0(city, &amp;quot;  &amp;quot;, scales::dollar(price2015, scale = 1e-3, accuracy = 1, suffix = &amp;quot;k&amp;quot;)))

p_direct_map &amp;lt;- ggplot() +
  geom_sf(data = tx_outline, fill = &amp;quot;grey96&amp;quot;, color = &amp;quot;grey80&amp;quot;, linewidth = 0.3) +
  geom_point(data = core, aes(lon, lat, color = price2015), size = 2.6, show.legend = FALSE) +
  geom_text_repel(data = filter(core, is_extreme), aes(lon, lat, label = lab), color = &amp;quot;black&amp;quot;,
                   size = 2.8, fontface = &amp;quot;bold&amp;quot;, force = 3, box.padding = 0.4,
                   min.segment.length = 0, segment.size = 0.3, segment.color = &amp;quot;grey60&amp;quot;,
                   max.overlaps = Inf) +
  scale_color_gradientn(colors = seq_blue) +
  theme_void(base_size = 12) +
  theme(plot.title = element_text(face = &amp;quot;bold&amp;quot;)) +
  labs(title = &amp;quot;With selective direct labels&amp;quot;)

plot_grid(p_legend_map, p_direct_map, nrow = 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;img src=&#34;https://nkaza.github.io/post/my-screed-against-legends/index.en_files/figure-html/unnamed-chunk-6-1.png&#34; alt=&#34;&#34; width=&#34;960&#34; /&gt;
&lt;p&gt;Two things are doing the work in the right-hand panel. One is choosing the six labelled cities ahead of time to show the extremes (highest and lowest median price), so the reader can interpolate the rest. Austin and Midland turn up as the two highest, which is a real result worth pausing on: Midland&amp;rsquo;s 2015 prices, driven by the Permian Basin oil boom, briefly rivalled Dallas&amp;rsquo;s. That&amp;rsquo;s exactly the kind of specific, nameable fact a legend&amp;rsquo;s colour bar would never surface on its own.&lt;/p&gt;
&lt;p&gt;Second, &lt;code&gt;geom_text_repel()&lt;/code&gt; pushes a label off its point and onto the plain map background whenever there&amp;rsquo;s no room on top of it, with a leader line whenever two dots sit close together.&lt;/p&gt;
&lt;p&gt;A few more choices worth noting: each market is a point here, not a polygon. In areal choropleths, large areas dominate visual attention regardless of the value they&amp;rsquo;re actually showing. &lt;a href=&#34;https://medium.com/tdebeus/trump-should-ignore-the-mercator-map-when-showing-election-results-52ad2d33b740&#34; target=&#34;_blank&#34; rel=&#34;noopener&#34;&gt;Often this conveys the wrong point entirely&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Much to the chagrin of my cartographer friends, I also deliberately leave out the north arrow and the scale bar. That is a fight for a different post.&lt;/p&gt;
&lt;p&gt;However, we need to ask if this map makes any sense at all in the first instance. Is the point of the map to show broad spatial clusters? If so, no obvious spatial clusters exist, i.e. the famous Tobler first law, after all is not a law. The only discernible pattern is that there is an expensive market surrounded by inexpensive markets around Dallas. That point could be made much more succinctly using a different visualisation. I leave that as an exercise.&lt;/p&gt;
&lt;h3 id=&#34;symbols-are-no-different-than--colours&#34;&gt;Symbols are no different than  colours&lt;/h3&gt;
&lt;p&gt;Everything so far has been about colour, but the same argument applies to the shape channel. A common way to mark a grouping without relying on colour at all is to map to a shape. That still needs a legend, for the same reason a colour legend does. In the markets in Texas, if we want to explore the relationship between sales volume and price, differentiated by region, we can do the following:&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Show code&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;sales2015 &amp;lt;- txhousing %&amp;gt;% filter(city %in% core$city, year == 2015) %&amp;gt;%
  group_by(city) %&amp;gt;% summarise(sales2015 = sum(sales, na.rm = TRUE))
core &amp;lt;- core %&amp;gt;% left_join(sales2015, by = &amp;quot;city&amp;quot;) %&amp;gt;% mutate(log_sales = log10(sales2015))
shapes6 &amp;lt;- c(16, 17, 15, 18, 3, 4)

fit_lines &amp;lt;- lapply(split(core, core$region), function(d) {
  m &amp;lt;- lm(price2015 ~ log_sales, data = d)
  xs &amp;lt;- seq(min(d$log_sales), max(d$log_sales), length.out = 60)
  data.frame(region = d$region[1], log_sales = xs,
             price2015 = predict(m, newdata = data.frame(log_sales = xs)))
}) %&amp;gt;% bind_rows()

marker_pts &amp;lt;- fit_lines %&amp;gt;% group_by(region) %&amp;gt;%
  slice(round(seq(1, n(), length.out = 4))) %&amp;gt;% ungroup()

p_legend_sym &amp;lt;- ggplot() +
  geom_point(data = core, aes(log_sales, price2015, shape = region), color = &amp;quot;black&amp;quot;, size = 1.6) +
  scale_shape_manual(values = shapes6) +
  scale_x_continuous(breaks = 3:4, labels = function(x) scales::comma(10^x)) +
  scale_y_continuous(labels = scales::dollar_format(scale = 1e-3, suffix = &amp;quot;k&amp;quot;)) +
  labs(x = &amp;quot;2015 home sales (log scale)&amp;quot;, y = &amp;quot;2015 median price&amp;quot;, shape = NULL, title = &amp;quot;With a legend&amp;quot;) +
  base_theme +
  theme(legend.position = &amp;quot;right&amp;quot;)

line_ends &amp;lt;- fit_lines %&amp;gt;% group_by(region) %&amp;gt;% slice_max(log_sales, n = 1) %&amp;gt;% ungroup()

# West Texas is the one region worth calling out in colour --- see the text
# below; everything else stays black, distinguished only by shape. Red is
# cols4all&#39;s misc.okabe vermillion, not base R&#39;s harsher &amp;quot;red&amp;quot;, so it stays
# consistent with every other colour choice in this post
red &amp;lt;- &amp;quot;#D55E00&amp;quot;
core_hl       &amp;lt;- core       %&amp;gt;% mutate(hl = if_else(region == &amp;quot;West Texas&amp;quot;, red, &amp;quot;black&amp;quot;))
fit_lines_hl  &amp;lt;- fit_lines  %&amp;gt;% mutate(hl = if_else(region == &amp;quot;West Texas&amp;quot;, red, &amp;quot;black&amp;quot;))
marker_pts_hl &amp;lt;- marker_pts %&amp;gt;% mutate(hl = if_else(region == &amp;quot;West Texas&amp;quot;, red, &amp;quot;black&amp;quot;))
line_ends_hl  &amp;lt;- line_ends  %&amp;gt;% mutate(hl = if_else(region == &amp;quot;West Texas&amp;quot;, red, &amp;quot;black&amp;quot;))

p_direct_sym &amp;lt;- ggplot() +
  geom_point(data = core_hl, aes(log_sales, price2015, shape = region, color = hl), size = 1.6, show.legend = FALSE) +
  geom_line(data = fit_lines_hl, aes(log_sales, price2015, group = region, color = hl), linewidth = 0.6, alpha = .5, show.legend = FALSE) +
  geom_point(data = marker_pts_hl, aes(log_sales, price2015, shape = region, color = hl), size = 2.6, alpha = .5, show.legend = FALSE) +
  geom_text_repel(data = line_ends_hl, aes(log_sales, price2015, label = region, color = hl),
                   fontface = &amp;quot;italic&amp;quot;, size = 3.2, hjust = 0, direction = &amp;quot;y&amp;quot;,
                   xlim = c(max(fit_lines$log_sales) + 0.05, NA),
                   force = 2, box.padding = 0.3, min.segment.length = 0,
                   segment.size = 0.3, segment.color = &amp;quot;grey60&amp;quot;, show.legend = FALSE) +
  scale_color_identity() +
  scale_shape_manual(values = shapes6) +
  scale_x_continuous(breaks = 3:4, labels = function(x) scales::comma(10^x),
                      expand = expansion(mult = c(0.05, 0.55))) +
  scale_y_continuous(labels = scales::dollar_format(scale = 1e-3, suffix = &amp;quot;k&amp;quot;)) +
  labs(x = &amp;quot;2015 home sales (log scale)&amp;quot;, y = &amp;quot;2015 median price&amp;quot;, title = &amp;quot;With direct labels&amp;quot;) +
  base_theme +
  theme(plot.margin = margin(5.5, 12, 5.5, 5.5))

plot_grid(p_legend_sym, p_direct_sym, nrow = 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;img src=&#34;https://nkaza.github.io/post/my-screed-against-legends/index.en_files/figure-html/unnamed-chunk-7-1.png&#34; alt=&#34;&#34; width=&#34;960&#34; /&gt;
&lt;p&gt;The direct-label version keeps the shapes, so a reader can tell the six lines apart without a key even before reaching a label. If the focus is each group&amp;rsquo;s general trend, keeping these fitted lines earns its place. The West Texas market looks radically different in trend from the rest of the regions and is worth calling out. Showing what each region&amp;rsquo;s data actually does is far more useful than showing which shape is linked to which region.&lt;/p&gt;
&lt;p&gt;In other cases (e.g. clusters that are well separated, or a handful of genuine outliers), calling them out directly using standard ellipses, bounding boxes, explicit tagging, etc., is worth the effort.&lt;/p&gt;
&lt;h3 id=&#34;make-the-legend-do-some-work&#34;&gt;Make the legend do some work&lt;/h3&gt;
&lt;p&gt;None of this is to say legends are never useful. But if you&amp;rsquo;re going to keep one, perhaps make it earn its keep. A plain colour bar does the least a legend possibly could: one axis, one relationship, nothing else. Swap the strip for a histogram, coloured with the same scale, and the legend can carry information the map doesn&amp;rsquo;t provide on its own.&lt;/p&gt;
&lt;p&gt;In what follows, reading the dot map hasn&amp;rsquo;t changed at all. What&amp;rsquo;s changed is that the legend itself now says something: most Texas markets sit in a $140k&amp;ndash;180k band, and a handful (Austin, Dallas, Midland) sit well clear of it by themselves. The same trick works for a categorical legend: add a count to each label, sort them, and a flat list of swatches becomes a ranking. I leave this, as an exercise.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Show code&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&#34;language-r&#34;&gt;# core, tx_outline, seq_blue, and p_legend_map were all built in
# &amp;quot;A continuous map&amp;quot; above; reused as-is here
# p_legend_map (built in &amp;quot;A continuous map&amp;quot; above) puts its colour bar on
# the right by default. Moved to the bottom here, at roughly the same width
# and position as the histogram inset below, so the two panels are actually
# comparable side by side rather than differing in layout as well as content
p_legend_map_bottom &amp;lt;- p_legend_map +
  labs(color = &amp;quot;2015 median price&amp;quot;) +
  guides(color = guide_colorbar(barwidth = 12, barheight = 0.5, title.position = &amp;quot;top&amp;quot;, title.hjust = 0.5)) +
  theme(legend.position = &amp;quot;bottom&amp;quot;, legend.title = element_text(size = 9), legend.text = element_text(size = 8))

p_map_bare &amp;lt;- ggplot() +
  geom_sf(data = tx_outline, fill = &amp;quot;grey96&amp;quot;, color = &amp;quot;grey80&amp;quot;, linewidth = 0.3) +
  geom_point(data = core, aes(lon, lat, color = price2015), size = 2.6, show.legend = FALSE) +
  scale_color_gradientn(colors = seq_blue) +
  theme_void(base_size = 12) +
  theme(plot.title = element_text(face = &amp;quot;bold&amp;quot;)) +
  labs(title = &amp;quot;With a legend that does some work&amp;quot;)

p_hist_legend &amp;lt;- ggplot(core, aes(price2015)) +
  geom_histogram(aes(fill = after_stat(x), color = after_stat(x)), bins = 55, linewidth = 0.3) +
  scale_fill_gradientn(colors = seq_blue, guide = &amp;quot;none&amp;quot;) +
  scale_color_gradientn(colors = seq_blue, guide = &amp;quot;none&amp;quot;) +
  scale_x_continuous(labels = scales::dollar_format(scale = 1e-3, suffix = &amp;quot;k&amp;quot;)) +
  labs(x = &amp;quot;2015 median price&amp;quot;, y = NULL) +
  theme_minimal(base_size = 12) +
  theme(panel.grid = element_blank(),
        axis.title.y = element_blank(),
        axis.text.y = element_blank(),
        axis.ticks.y = element_blank(),
        axis.text.x = element_text(size = 8, color = &amp;quot;grey40&amp;quot;),
        axis.title.x = element_text(size = 9, color = &amp;quot;grey30&amp;quot;),
        axis.line.x = element_line(color = &amp;quot;grey50&amp;quot;, linewidth = 0.3),
        plot.background = element_rect(fill = &amp;quot;transparent&amp;quot;, color = NA),
        panel.background = element_rect(fill = &amp;quot;transparent&amp;quot;, color = NA))

# draw_plot() places the histogram as a small inset rather than a full-width
# panel stacked under the map --- a legend doesn&#39;t need to be as wide as the
# thing it&#39;s labelling
p_right &amp;lt;- ggdraw(p_map_bare) +
  draw_plot(p_hist_legend, x = 0.08, y = 0.03, width = 0.55, height = 0.17)

plot_grid(p_legend_map_bottom, p_right, nrow = 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;img src=&#34;https://nkaza.github.io/post/my-screed-against-legends/index.en_files/figure-html/unnamed-chunk-8-1.png&#34; alt=&#34;&#34; width=&#34;960&#34; /&gt;
&lt;h2 id=&#34;conclusions&#34;&gt;Conclusions&lt;/h2&gt;
&lt;p&gt;I was tempted to title this post similar to &lt;a href=&#34;https://kieranhealy.org/files/papers/fuck-nuance.pdf&#34; target=&#34;_blank&#34; rel=&#34;noopener&#34;&gt;Kieran Healy&amp;rsquo;s article&lt;/a&gt;, but thought better of it.
However, the sentiment carries.&lt;/p&gt;
&lt;p&gt;In summary, make it easy for the reader, not for yourself. Label selectively and judiciously, never exhaustively. Pick the right channel &amp;mdash; symbol, colour, position, text &amp;mdash; for the right message. Stop asking the reader to hold some arbitrary mapping in their head, and put the information where their eyes already are.&lt;/p&gt;
</description>
    </item>
    
  </channel>
</rss>
