6-mile Hex Atlas of Earth
I made a collection of over 100 6-mile hex maps covering most of the Earth's surface.
I'm aware of a couple defunct attempts at realizing this idea on Reddit. My contribution is of lower quality than either of those would have been, but... it's finished & free.
As I hopefully made clear on the itch.io page, these are intended as a resource/reference -- I wouldn't consider them 'table-ready', but if a higher-quality map of your desired region doesn't exist these can hopefully be a good starting point for your historical/alt-historical/post-apocalyptic setting.
I'm also hoping they'll be useful as a reference (or reality check) for comparing fantasy hex maps to real-world areas.
Projections
Hex-mapping a whole planet poses interesting (& annoying) technical challenges, foremost being the fact that hexagons don't tessellate over a sphere. I kicked around several possible solutions:
Option A: Use a geodesic grid
Although hex grids can't tessellate over a sphere, it's possible to subdivide an icosahedron such that the faces are covered by hexes and the corners by a pentagon. There's even an R package for it.
This is almost certainly the best way to cover the earth with maximally unifor polygons. So why didn't I pursue it? Unfortunately, optimizing for real-world area doesn't align with usability or aesthetics. The orientation of each hex varies by where you are in the world, and since maps will ultimately be displayed on flat surface, I'd still have to solve the projection issue or have visibly distorted hexes.
A geodesic grid over South Africa from the dggridR
package vignette. Note the distortion in the southern hexes and the fact that hex edges aren't aligned with map edges.
Option B: Use an esoteric but maximally flat projection
Thanks to xkcd everyone knows about the Dymaxion & Waterman Butterfly projections -- in theory, one could overlay one of these with a hex grid and have a reasonably good representation. Unfortunately, they're proprietary & not available through QGIS, so I didn't pursue this option.
Option C: Say 'fuck it' and just overlay hexes over a Mercator or equirectangular projection
The geography police would sentence me to 20 years of hard labor measuring Greenland if I did this.
Option D: Custom projections for each map
Having discounted A-C, the least-bad option seems to be manually slicing up the earth into focus areas and then projecting each to a unique, maximally flat coordinate reference system.
This isn't so bad -- the globe will end up divided into usably-sized maps anyways, and thanks to proj syntax it's easy to define custom projections in R.
So I manually delineated roughly 100 regions based on geography & history, as well as some areas of likely ttrpg interest.
I wrote an R script that loops through the polygons, calculates a latitude-longitude bounding box, and projects them into a custom Albers equal-area projection with standard parallels at the top & bottom of the bounding box and the central meridian at bounding box center:
- Using an equal-area projection ensures that hexes all cover the same land area.
- Setting the standard parallels minimizes distortion (technically I could minimize it further by tightenting them slightly, as minimal distortion extends north and south of each parallel, but it's minor for the areas in question).
- Setting the central meridian ensures that north is 'up' on the map (Albers projections 'wrap around' the north pole at large scales).
(Albers projection with parallels at 30 and 42 degrees North and a central meridian at the prime meridian).
Terrain
I wanted to go beyond merely superimposing a grid over satellite imagery or a topographic map, so I incorporated data from a USGS ecological land units map. ELUs incorporate over 400 combinations of topography, temperature, moisture, and vegetation type, so my script groups them into 10 easily-intelligible categories.
For mountains, I used a map of global mountains developed by the same team. After some trial-and-error I found that excluding pixels marked as 'scattered' mountains looked best (including all mountain pixels resulted in regions like Japan and Anatolia being covered almost entirely in mountains).
I tried treating mountains & hills as a separate layer overlapping with biome but found that it made the maps too difficult to parse visually.
Mapping
Output maps were generated using the tmap
package for R. I opted to use solid colors to symbolize terrain types -- fast to render, forgiving at different scales, and easy for users to alter via the fill tool in any image editor.
Random Thoughts
First, 6-mile hexes are small and Earth is marvelously vast. I won't wade into hex-size wars again, but the number of hexes it takes to cover even small and medium-sized countries is kind of wild.
A second, minor note: I was struck by the abundance of lakes in many areas (afaik especially those with recent glacial activity, like Northern Canada and Finland). I don't usually see people speckling 1-to-3-hex glacial lakes into their maps, but they probably should -- it's a neat terrain barrier for adding pathcrawl elements.
Source Code
library(terra)
library(tidyverse)
library(tidyterra)
library(tmap)
maps <- vect("Atlas_maps.gpkg") # maps to loop through
#mountains <- rast("k3classes.tif") #
biomes <- rast(file.path('USGSEsriTNCWorldTerrestrialEcosystems2020', 'commondata', 'raster_data', 'WorldEcosystem.tif'))
activeCat(biomes) <- 10
biome_attrs <- cats(biomes)[[1]] %>%
mutate(Terrain = ifelse(LC_ClassNa %in% c('Settlement', 'Cropland', 'Grassland', 'Shrubland'), 'Open', NA)) %>%
mutate(Terrain = ifelse(is.na(Terrain) & LC_ClassNa == 'Forest' & Temp_Class %in% c('Polar', 'Boreal'), 'Taiga', Terrain)) %>%
mutate(Terrain = ifelse(is.na(Terrain) & LC_ClassNa == 'Forest' & Temp_Class %in% c('Cool Temperate', 'Warm Temperate', 'Sub Tropical'), 'Forest', Terrain)) %>%
mutate(Terrain = ifelse(is.na(Terrain) & LC_ClassNa == 'Forest' & Temp_Class %in% c('Tropical'), 'Tropical Forest', Terrain)) %>%
mutate(Terrain = ifelse(Moisture_C == "Desert" & Terrain %in% c('Open', 'Forest', 'Tropical Forest'), 'Scrub Desert', Terrain)) %>%
mutate(Terrain = ifelse(LC_ClassNa == 'Sparsely or Non-vegetated' & Moisture_C %in% c('Desert', 'Dry'), 'Bare Desert', Terrain)) %>%
mutate(Terrain = ifelse(LC_ClassNa == 'Sparsely or Non-vegetated' & Temp_Class %in% c('Polar', 'Boreal'), 'Tundra', Terrain)) %>%
mutate(Terrain = ifelse(LC_ClassNa == 'Snow and Ice', 'Glacier', Terrain)) %>%
#mutate(Terrain = ifelse(LF_ClassNa == 'Mountains', 'Mountains', Terrain)) %>%
mutate(Terrain = ifelse(is.na(Terrain) & LC_ClassNa == 'Sparsely or Non-vegetated', 'Barren', Terrain)) %>%
mutate(Terrain = ifelse(is.na(Terrain), 'Open', Terrain))
res(biomes)
#biomes[is.na(biomes)] <- 0
#biomes_agg <- terra::aggregate(biomes, 17, fun = 'modal', na.rm=T)
#plot(biomes_agg)
#rm(biomes)
if (!file.exists('biomes_aggregate.tif')){
biomes_agg2 <- ifel(is.na(biomes), 0, biomes) %>%
focal(w=7, fun='modal') %>%
addCats(biome_attrs %>% select(Value, Terrain))
activeCat(biomes_agg2) <- "Terrain"
writeRaster(biomes_agg2, 'biomes_aggregate.tif')
} else {
biomes_agg2 <- rast('biomes_aggregate.tif')
activeCat(biomes_agg2) <- "Terrain"
}
#plot(biomes_agg2)
hills_mountains <-rast("k3classes.tif")
activeCat(hills_mountains) <- 'Rowid'
for (i in sample(1:nrow(maps), nrow(maps))){
map <- maps[i]
if (file.exists(file.path('Outputs', map$Continent, paste0(map$Name,'.png')))) {next}
print(paste('Generating map for', map$Name))
map_center <- centroids(map)
custom_crs <- sprintf("+proj=aea +lat_1=%f +lat_2=%f +lon_0=%f +datum=WGS84", ext(map)[3], ext(map)[4], crds(map_center)[1])
#custom_crs <- sprintf("+proj=merc +lat_ts=%f +lon_0=%f +datum=WGS84", crds(map_center)[2], crds(map_center)[1])
map_reproject <- project(map, custom_crs)
grid <- sf::st_make_grid(sf::st_as_sf(map_reproject), cellsize = 9600, square=F, flat_topped = T) %>% vect()
grid$ID <- 1:nrow(grid)
grid$x <- geom(centroids(grid))[,'x']
grid$y <- geom(centroids(grid))[,'y']
print(paste(nrow(grid), 'hexes...'))
xidx.df <- data.frame(x = unique(grid$x)) %>%
arrange(x) %>%
mutate(col_idx = row_number())
yidx.df <- data.frame(y = unique(grid$y)) %>%
arrange(y) %>%
mutate(row_idx = row_number()) # account for the staggered rows, I think this should work??
grid <- grid %>% left_join(xidx.df) %>% left_join(yidx.df)
# project hex centers back into mercator for sampling the raster
# much faster than projecting the raster
sampling_grid <- grid %>% centroids() %>% project(crs(biomes_agg2))
sample <- biomes_agg2 %>% terra::extract(sampling_grid)
mtnsample <- hills_mountains %>% terra::extract(sampling_grid)
levels(sample$Terrain) <- c(levels(sample$Terrain), 'Mountains')
sample[mtnsample$Rowid %in% c('0', '2'), 'Terrain'] <- 'Mountains'
levels(sample$Terrain) <- c(levels(sample$Terrain), 'Water')
sample[is.na(sample$Terrain), 'Terrain'] <- 'Water'
# then add the sampled data to the Albers equal area grid
grid <- grid %>% left_join(sample)
print(paste(grid %>% filter(Terrain!='Water') %>% nrow(), 'land hexes...'))
#plot(grid, "Terrain")
# generate map
outmap <- tm_shape(grid) +
tm_polygons(
'Terrain',
col_alpha = 0.2,
fill.scale = tm_scale_categorical(values = c('slategray1', 'white',
'palegreen', 'darkgreen','forestgreen', 'gray',
'khaki', 'goldenrod', 'olivedrab', 'tan4', 'steelblue'))) +
tm_shape(World_rivers) + tm_lines(lwd = "strokelwd",
lwd.scale = tm_scale_asis(values.scale = .5),
col = "steelblue") +
tm_layout(
inner.margins = c(0, 0, 0, 0),
outer.margins = c(0,0,0,0),
bg.color = 'white',
frame = F,
title.position = tm_pos_out(cell.h = 'right', cell.v = 'center'),
#legend.position = tm_pos_out(cell.h = 'right'),
legend.bg.alpha = 0.55,
legend.show = F
)
#+
#tm_text(text = "row_idx")
# export map
map_width <- ext(grid)[2] - ext(grid)[1]
map_height <- ext(grid)[4] - ext(grid)[3]
print('Writing map to disk...')
ts <- Sys.time()
tmap_save(outmap, filename = file.path('Outputs', map$Continent, paste0(map$Name,'.png')), width = (map_width/500)+200, scale = 0.33)
print(paste('...done.', Sys.time() - ts))
}