Maps / spatial pages: Maps | Spatial statistics | Spatial interpolation
We prepare maps for environmental reporting for two main reasons:
To show the site being investigated in its context, such as site boundaries, surrounding land use, potential broader sources of contamination, proximity to receiving environments, and so on. This is called a “Locality Map”.
To show the relevant details of the site being investigated: relevant infrastructure, specific suspected sources of contamination, sample types and locations, samples with contaminant conentrations exceeding guideline values, etc. This is called a “Site Plan map”.
This page presents a simple workflow to prepare both Locality and Site Plan maps using relevant packages in R.
Install the required packages to your device if you don't have them already (only do this once, not every R session):
if(!require(sf)) install.packages("sf")
if(!require(maptiles)) install.packages("maptiles")
if(!require(prettymapr)) install.packages("prettymapr")
if(!require(TeachingDemos)) install.packages("TeachingDemos")
if(!require(viridis)) install.packages("viridis")
if(!require(ggmap)) install.packages("ggmap")
if(!require(ggplot2)) install.packages("ggplot2")
if(!require(ggspatial)) install.packages("ggspatial")
You will need to load the installed packages each time you start RStudio.
library(sf) # essential for maps and spatial analysis in R
library(maptiles) # for downloading map tile images
library(prettymapr) # for making north arrow and scale bar
library(TeachingDemos) # for shadowtext() function (outlined text)
library(viridis) # for accessible colour palettes
palette(c("black", viridis(8), "royalblue",
"white", "transparent", "firebrick", "grey44")) # custom colours
library(ggmap)
library(ggplot2)
library(ggspatial)
This code uses some functions from the sf
R package(
)Pebesma, 2018) to import some data from .kml
files (Google
Earth format), and transform the resulting spatial sf
objects to the projection we are using. For more detail on the
sf
package, map projections, etc., look at the Introduction to Maps in R
page.
The st_read()
function can import data from numerous
formats such as ESRI shapefiles and Google Earth .kml
files. The st_transform
function converts the
longitude-latitude coordinates in the .kml
files (units of
degrees) to the coordinate reference system (CRS) specified by the
function st_crs()
called within st_transform
.
The CRS is specified by the EPSG code, where 32750
indicates UTM
Zone 50 S based on the WGS84 datum, with units of metres.
git <- "https://github.com/Ratey-AtUWA/Learn-R-web/raw/refs/heads/main/"
LC_site <- st_read(paste0(git, "LC_UWA_study_area.kml"))
LC_site <- st_transform(LC_site, crs=st_crs(32750)) # convert to UTM Zone 50S
Claremont_drains <- st_read(paste0(git, "Town-of-Claremont-Drains.kml"))
(Claremont_drains <- st_transform(Claremont_drains,
crs=st_crs(32750))) # convert to UTM Zone 50S
Australian guidelines for reporting on contaminated site investigations (NEPC 2011, DWER 2021) specify including a locality map "...showing site location, site boundary, cadastral boundary or boundaries, surrounding area and any key nearby features..." (DWER, 2021).
The first part of this code shows a method for producing a locality
map in R, relying on the sf
spatial R package (Pebesma and
Bivand, 2023) together with the packages maptiles
(Giraud,
2024) and prettymapr
(Dunnington, 2024).
Find coordinates by:
Show Lat/Long
after clicking Tools/Options... , then
Add Placemark 📌
⚠ THESE COORDINATES 👇 ARE NOT REAL! - PLEASE FIND YOUR OWN!
locality <- st_as_sf(data.frame(x=c(123456,123456), y=c(1234567,1234567)),
coords=c("x","y"), crs=st_ evalcrs(32750))
locality # check it worked: should include 'Projected CRS: WGS 84 / UTM zone 50S'
get_tiles()
function with options to download
map tilesThe code above made an object locality
which defined our
map rectangle in our chosen coordinate system. The locality
object then becomes an input for the get_tiles()
function
from the maptiles
R package. We specify some other options
as well: provider=
sets the map tile style, and
zoom=
sets the level of detail. At each zoom level, map
tiles are squares, so we use crop=TRUE
to trim any unneeded
parts of the map tile images.
The best option for plotting seems to be to:
Plot an empty map frame based on the extent object
(locality
), adding axis labels and specifying
xaxs="i", yaxs="i"
to suppress axis padding.
Use the plot_tiles()
function from the
maptiles
R package to add the map tile layer. We include
the option add=TRUE
to plot on top of the previous
plot.
# first setting graphics parameters with par()
par(mar=c(3,3,1,1), mgp=c(1.5,0.3,0), font.lab=2)
# first an empty plot frame based on the map extent
plot(st_coordinates(locality), type="n", asp=1, xaxs="i", yaxs="i",
xlab="Easting", ylab="Northing")
plot_tiles(claremontMap, add=TRUE)
box() # redraw plot frame
After plotting the map in RStudio, you will probably need to adjust the size and proportions of your plot area manually, so that the map tiles fill the whole plot frame.
The guidelines state that a North arrow and scale bar should be included on maps, as well as the locations of significant geographical features (natural or anthropogenic).
# ...continuing code from above
addnortharrow()
addscalebar(plotepsg = 32750, pos="bottomleft", label.col = 1, linecol = 1,
label.cex = 1.2, htin=0.15, widthhint=0.3, padin=c(0.15,0.25))
mtext("CRS:UTM Zone 50S, WGS84 (EPSG:32750)", side=1, line=-1.2,
font=2, col=14, cex=0.75, adj=0.02)
shadowtext(384350,6461820, labels="Lake\nClaremont", col=3, bg=11, cex=0.85, font=2)
shadowtext(385000,6461120, labels="Fremantle Railway", col=5, bg=11, cex=0.85, srt=25)
shadowtext(382900, 6462250, labels="Military\nFacility", col=5, bg=11, cex=0.85)
shadowtext(382100, 6462200, labels="Indian Ocean", col=3, bg=11, font=3, srt=90, cex=0.85)
We should always indicate the location of the site under investigation on our Locality Map. We also include stormwater drains on this map, since stormwater may affect water quality in Lake Claremont. Adding a legend helps readers with map interpretation.
# ...continuing code from above
plot(Claremont_drains[1], add=TRUE, type="l", col=10, lwd=2)
plot(LC_site[1], add=T, type="l", col=13, lwd=3)
legend("topleft", bg="#ffffffa0", box.col=12, inset=0.02,
legend=c("Lake Claremont study site",
"Town of Claremont stormwater drains"),
lwd=c(3,2), col=c(13,10))
ggmap
/ggplot2
The ggplot2
R package is a popular alternative to base-R
plotting (when using maptiles
and sf
we are
still essentiall using base R graphics). The relevant functions for
ggplot2
are geom_sf()
and
annotation_raster()
, as the code below shows. We also make
use of:
get_map()
and related functions in the
ggmap
package, to obtain georeferenced raster images based
on tiles from map servers;annotation_scale()
and
annotation_north_arrow()
functions in the
ggspatial
package to add essential map annotations.As with maptiles
maps, we need to specify a rectangle
for our map background raster. In this example we're going to use the
object locality
that we made previously, and convert it to
WGS84 Long-Lat (EPSG:4326) using st_transform()
, as this is
the CRS required by ggmap
.
## Find long-lat bounding coordinates for ggmap raster background:
## [1] 115.75097 -31.98604 115.79889 -31.96359
# transform the site boundary data to new CRS
LC_siteLL <- st_transform(LC_site, crs=st_crs(4326))
# convert to sf:: object of class `polygon`
study <- st_polygon(list(as.matrix(st_coordinates(LC_siteLL)))) |>
st_sfc(crs=st_crs(4326))
This example uses stamen_terrain
map tiles from Stadia.
In ggmap
, the Stadia maps can be specified from a
user-defined rectangle (which is usually what we want), but other map
tiles (e.g. google) yield square maps around a central coordinate. The
get_stadiamap()
function creates a georeferenced raster
image from Stadia Stamen map tiles, which can be plotted directly using
ggmap()
. We're going to use ggplot()
instead,
as it is then easier to add additional map layers such as the
investigation site boundary, stormwater drains, land use polygons, and
so on. In this case we use annotation_raster
to add the
ggmap
raster to our map plot.
You will need to register for a (free) API key if you want to use Stadia Stamen map tiles.
register_stadiamaps(key = smapskey) # do this before getting Stadia Stamen maps
localgg <-
get_stadiamap(bbox=c(left=ll[1], bottom=ll[2], right=ll[3], top=ll[4]),
maptype = "stamen_terrain", zoom=15)
Here's how we plot the map background, additional map layers, and annotations (Figure 4).
# make an object to contain custom plot colours
cols <- c("Storm_Drains"="darkcyan", "Study_Area"="#800080b0")
# plot the `ggmap` raster starting with an empty `ggplot()`
ggplot() +
annotation_raster(localgg, xmin=ll[1], ymin=ll[2], xmax=ll[3], ymax=ll[4]) +
scale_x_continuous(expand = c(0,0), limits=c(ll[1],ll[3])) +
scale_y_continuous(expand = c(0,0), limits=c(ll[2],ll[4])) +
# xlim(c(ll[1],ll[3])) + ylim(c(ll[2], ll[4])) +
geom_sf(data=study, mapping=aes(col="Study_Area"), fill=NA, lwd=1.3) +
geom_sf(data=Claremont_drains, mapping=aes(col="Storm_Drains"),
lwd=0.8, lty="21") +
geom_text(aes(x = c(115.76,115.778), y = c(-31.968,-31.973),
label = c("Military\nFacility","Study\nArea"),
fontface="italic", family="sans"),
size=12, vjust=0.5, hjust=0.5, color="steelblue4", size.unit="pt") +
geom_text(aes(x = 115.771, y = -31.9818,
label = c("Fremantle Railway"),
fontface="italic", family="sans"), angle=18,
size=10, vjust=0.5, hjust=0.5, color="steelblue4", size.unit="pt") +
geom_text(aes(x = c(115.752), y = c(-31.9725),
label = c("Indian Ocean"),
fontface="italic", family="sans"), angle=90,
size=12, vjust=0.5, hjust=0.5, color="navy", size.unit="pt") +
labs(x="Longitude", y="Latitude") +
scale_color_manual(name="", values=cols,
guide=guide_legend()) +
annotation_scale(location="tl", width_hint=0.25, text_cex=1,
pad_x=unit(2, "cm")) +
annotation_north_arrow(location = "tl", which_north = "true",
pad_x = unit(0.2, "cm"), pad_y = unit(0.2, "cm"),
style = north_arrow_fancy_orienteering) +
theme_bw() +
theme(axis.title = element_text(face="bold", size=14),
axis.text = element_text(size=12),
legend.text = element_text(size=12))
Some additional notes on this code:
scale_x_continuous(expand = c(0,0), limits=c(ll[1],ll[3]))
(and scale_y_continuous()
) allow us to have axis limits
that exactly fit the ggmap
rastergeom_sf()
allows us to add layers. The layer
information eeds to be in a sf
objectgeom_text()
labs()
scale_color_manual()
creates the legend to the right of
the mapannotation_scale()
adds a scale bar (based on the CRS
of the raster)annotation_north_arrow()
adds a north arrowtheme_bw
is a good choice for mapstheme()
directly and setting some additional options.
Australian guidelines for reporting on preliminary (PSI) and detailed (DSI) site investigations (NEPC, 2011) also specify including a Site Plan drawn to a scale appropriate to project size and required detail. This plan is essentially a map showing features such as site boundary, sample locations, actual installed locations of monitoring wells, boreholes and/or pits, and so on (DWER, 2021). The Site Plan should also be used for reporting results, including “… a clear representation of contamination issues associated with the site…” (NEPC, 2011).
This section will explain the creation of such a more detailed Site map in R, including information on potential contamination at discrete sampling points.
We already have an object LC_site
that defines the
boundaries of the site so we can use that to specify the area of map
tiles to download. Note that we are choosing a different map tile style
- the CartoDB tiles have lighter colours, so are good for showing data
on.
LC_siteMap <- get_tiles(LC_site, provider="CartoDB.Voyager", crop=TRUE,
zoom=16)
par(mar=c(3,3,1,1), mgp=c(1.5,0.3,0), font.lab=2)
plot(st_coordinates(LC_site), type="n", asp=1, xaxs="i", yaxs="i",
xlab="Easting", ylab="Northing")
plot_tiles(LC_siteMap, add=TRUE)
box()
addnortharrow()
addscalebar(plotepsg = 32750, pos="bottomright", label.col = 1, linecol = 1,
label.cex = 1.2, htin=0.15, widthhint=0.3, padin=c(0.15,0.2))
mtext("UTM Zone 50S, WGS84 (EPSG:32750)", side=1, line=-1.1,
font=2, col=14, cex=0.7, adj=0.98)
shadowtext(c(384450), c(6462180), labels=c("Rehabilitated bushland"),
bg=11, col=6, srt=30)
shadowtext(c(384300,384650,384680), c(6461500,6461850,6461440),
labels=c("Scotch College\nsports fields","Grassed\nParkland",
"Golf\ncourse"), bg=11, col=c(14,6,6), font=3)
plot(Claremont_drains[2], add=T, type="l", col=4, lwd=1.5, lty=3)
We need to read-in our data:
(this is the same one used for the
regression assignment – save it to your working directory in
RStudio!)
## 'data.frame': 51 obs. of 11 variables:
## $ Lab.Field.Group: chr "Group-01" "Group-01" "Group-01" "Group-01" ...
## $ Sample_ID : chr "LCW24_01" "LCW24_02" "LCW24_03" "LCW24_04" ...
## $ Location : chr "North-west lake" "North-west lake" "North-west lake" "North-west lake" ...
## $ Easting : int 384411 384405 384402 384411 384415 384346 384559 384568 384576 384585 ...
## $ Northing : int 6462067 6462067 6462065 6462075 6462080 6461978 6461907 6461927 6461947 6461967 ...
## $ Zone : chr "NW" "NW" "NW" "NW" ...
## $ pH : num 7.43 7.51 7.57 7.46 7.46 8.56 7.76 7.4 7.72 7.64 ...
## $ As : num 0.166 0.158 0.148 0.182 0.14 0.158 NA NA 0.043 NA ...
## $ Fe : num 0.283 0.168 0.109 0.073 0.054 0.021 0.174 0.065 0.699 0.334 ...
## $ P : num 0.353 0.295 0.292 0.461 0.359 0.043 0.028 0.014 0.061 0.056 ...
## $ Pb : num NA NA NA 0.0246 NA NA NA NA NA NA ...
We can optionally check sample locations (small +
symbols):
# ...continuing previous code for Figure 4...
with(lcw24, points(Easting, Northing, pch=3, col=14, cex=0.7))
legend("topleft", bg="#ffffffb0", box.col=12,
legend=c("Storm drains","Water samples"), lty=c(3,NA), pch=c(NA,3),
col=c(4,14), pt.cex=c(NA,0.7))
symbols()
functionThe base-R symbols()
function allows us to add the
scaled circle symbols ("bubbles"). Note that we make bubble radius
proportional to square root concentration – this makes bubble
area proportional to concentration, since human perception
considers size as area rather than diameter or radius.
Make sure you use these option in the symbols()
function: add=TRUE
, inches=FALSE
.
We also identify and plot the samples with P concentrations greater than the threshold for total P in wetlands in SW Australia (ANZECC, 2000), which is 60 µg/L = 0.06 mg/L
# ...continuing previous code...
# first use a simple algorithm to estimate a scale factor `s0` for the bubbles
axrg <- par("usr")[2]-par("usr")[1]
# first make small legend bubble, checking if it's zero
if (pretty(lcw24$P)[1] < 0.001) {
bublo <- pretty(lcw24$P)[2]/2
} else {
bublo <- pretty(lcw24$P)[1]
}
bubhi <- pretty(lcw24$P)[length(pretty(lcw24$P))]
s0 <- signif(0.05*axrg/sqrt(bubhi),2)
# add the scaled symbols ("bubbles")
# note we make bubble radius proportional to square root concentration
# this makes bubble area proportional to concentration, since human perception
# considers size as area rather than diameter or radius
with(lcw24, symbols(Easting, Northing, add=TRUE, circles=sqrt(P)*s0,
inches=FALSE, fg=2, bg="#A020F080"))
# next we identify and plot the samples with P concentrations greater than
# the ANZECC threshold for total P in wetlands in SW Australia, which is
# 60µg/L = 0.06mg/L
with(lcw24[which(lcw24$P>=0.06),c("Easting","Northing","P")],
points(Easting, Northing, pch=3, col=1, lwd=2.5))
# manual legend
rect(384400, 6462190, 384620, 6462330, lwd=2,
col="#ffffffb0", border="#80808080")
symbols(c(384450,384550),c(6462250,6462250), circles=s0*sqrt(c(bublo,bubhi)), add=T,
lwd=1, inches=F, fg = "purple", bg = "#8000FF40")
text(c(384500,384450,384550),c(6462320,6462230,6462250), cex=0.85,
labels=c("P (mg/L)",bublo,bubhi), pos = c(1,1,1), font=c(2,1,1),
offset=0)
# "normal" legend
legend("topleft", inset=0.01, bg="#ffffffa0", box.col=12, y.intersp=1.75,
legend=c("Water\nsamples", "Samples\nP ≥ 0.06mg/L", "Stormwater\ndrains"),
pch=c(21,3,NA), lty=c(NA,NA,3), col=c(2,1,4), lwd=c(NA,NA,1.5),
pt.cex=c(3,1,NA), pt.lwd=c(1,2.5,NA), pt.bg="#A020F080", cex=0.85)
This final map could be included in an environmental investigation report.
To plot a different element (e.g. Al) you would need to
ggplot2
and ggmap
We have already converted our site polygon to the correct CRS
(i.e. Long-Lat WGS84), storing it in LC_siteLL
. To
make a background map we add some space around the site polygon – we
could do this manually, but I've made a custom function pad_bbox()
to do this
simply (pad_bbox()
expands the bounding box by 5% in all
directions by default; we can change the expansion factor).
# (pad_bbox() is a custom function to expand a sf:: object in
# all directions by a specified proportion)
source("https://github.com/Ratey-AtUWA/Learn-R-web/raw/refs/heads/main/FUNCTION-pad_bbox.R")
l2 <- st_bbox(pad_bbox(LC_siteLL)) |> as.numeric()
sitegg <-
get_stadiamap(bbox=c(left=l2[1], bottom=l2[2], right=l2[3], top=l2[4]),
maptype = "stamen_terrain", zoom=16)
We need our data in the right format, so we need to do some
conversions (data frame to sf
data frame, and UTM Zone 50
(EPSG 32750) to Longitude-Latitude WGS 84 (EPSG:4326):
lcw24LL <- st_as_sf(lcw24, coords=c("Easting", "Northing"),
crs=st_crs(32750), remove=FALSE) |>
st_transform(crs=st_crs(4326))
Here's how we use ggplot
to plot the map background,
additional map layers, annotations, and data (Figure
8).
# make an object to contain custom plot colours
cols <- c("Storm_Drains"="darkcyan", "Study_Area"="#800080b0")
# plot the `ggmap` raster starting with an empty `ggplot()`
ggplot(LC_siteLL) +
annotation_raster(sitegg, xmin=l2[1], ymin=l2[2], xmax=l2[3], ymax=l2[4]) +
scale_x_continuous(expand = c(0,0), limits=c(l2[1],l2[3])) +
scale_y_continuous(expand = c(0,0), limits=c(l2[2],l2[4])) +
geom_sf(data=study, fill=NA, lwd=1.3, color="#800080b0") +
geom_sf(data=Claremont_drains, lwd=0.8, lty="21", col="darkcyan") +
geom_sf(data=lcw24LL, mapping=aes(size=P, fill=P), pch=21, color="#00000080") +
geom_sf(data=subset(lcw24LL, lcw24LL$P>=0.06), pch=3, col="#ffffff40", stroke=2) +
geom_sf(data=subset(lcw24LL, lcw24LL$P>=0.06), pch=3) +
scale_fill_viridis_c(alpha=0.7, option="plasma",
breaks=c(0.01,0.02, 0.05,0.1,0.2,0.4),
guide="legend", name="P (mg/L)") +
scale_size_continuous(range=c(0,12), breaks=c(0.01,0.02, 0.05,0.1,0.2,0.4),
name="P (mg/L)") +
labs(x="Longitude", y="Latitude") +
geom_text(aes(x=115.7755, y=-31.9765, label = "Sports\nFields"),
fontface="italic", family="sans", color="#205000",
size.unit="pt", size=10, vjust=0.5, hjust=0.5) +
geom_text(aes(x=115.78, y=-31.9775, label = "Golf and\nSwimming\nComplex"),
fontface="italic", family="sans", color="#205000",
size.unit="pt", size=10, vjust=0.5, hjust=0.5) +
annotation_scale(location="bl", width_hint=0.25, text_cex=0.9,
height = unit(0.25, "cm")) +
annotation_north_arrow(location="tl", which_north="true",
pad_x=unit(0.2, "cm"), pad_y=unit(0.2, "cm"),
width=unit(1, "cm"), height=unit(1.2, "cm")) +
theme_bw() +
theme(axis.title = element_text(face="bold", size=11),
axis.text = element_text(size=9),
legend.text = element_text(size=9))
Notes on the ggplot
Site Plan map code:
aes(size=P, fill=P)
within geom_sf(data=lcw24LL, ...)
P
) exceeds the guideline concentration
– this is to improve visibility, since the underlying symbol has a
thicker line width (stroke
) and contrasting semitransparent
colourscale_fill_viridis_c()
and
scale_size_continuous()
so that we just get a single legend
instead of one for size and one for fill colour
ANZECC/ARMCANZ. (2000). Australian and New Zealand Guidelines for Fresh and Marine Water Quality (Volume 1, The guidelines). Australian and New Zealand Environment and Conservation Council, Agriculture and Resource Management Council of Australia and New Zealand https://www.waterquality.gov.au/sites/default/files/documents/anzecc-armcanz-2000-guidelines-vol1.pdf – see Table 3.3.6.
Dunnington, Dewey (2017). prettymapr
: Scale Bar,
North Arrow, and Pretty Margins in R. R package version 0.2.2. https://CRAN.R-project.org/package=prettymapr.
Dunnington, D. (2023). ggspatial
: Spatial Data
Framework for ggplot2. R package version 1.1.9, https://CRAN.R-project.org/package=ggspatial.
DWER (2021). Assessment and management of contaminated sites. Department of Water and Environment Regulation, Government of Western Australia, Joondalup. https://www.wa.gov.au/system/files/2023-05/guideline-assessment-and-management-of-contaminated-sites.pdf.
Giraud T (2021). maptiles
: Download and Display Map
Tiles. R package version 0.3.0, https://CRAN.R-project.org/package=maptiles.
Garnier S, Ross N, Rudis R, Camargo AP, Sciaini M, Scherer C (2024).
viridis(Lite)
- Colorblind-Friendly Color Maps for
R. viridis package version 0.6.5. https://sjmgarnier.github.io/viridis/.
Kahle, D. and Wickham, H. ggmap
: Spatial Visualization
with ggplot2
. The R Journal,
5(1), 144-161. http://journal.r-project.org/archive/2013-1/kahle-wickham.pdf.
NEPC (National Environment Protection Council). (2011). Schedule B2: Guideline on Site Characterisation, National Environment Protection (Assessment of Site Contamination) Measure (Amended). Commonwealth of Australia, Canberra. https://www.nepc.gov.au/.../schedule-b2-guideline-site-characterisation-sep10.pdf.
Pebesma, E., 2018. Simple Features for R: Standardized Support for
Spatial VectorData. The R Journal 10 (1),
439-446, https://doi.org/10.32614/RJ-2018-009 . (package
sf
)
Water Quality Australia. (2024). Search for toxicant default guideline values for the protection of aquatic ecosystems. Department of Climate Change, Energy, the Environment and Water, Government of Australia. https://www.waterquality.gov.au/anz-guidelines/guideline-values/default/water-quality-toxicants/search.
Wickham, H. (2016). ggplot2
: Elegant Graphics for
Data Analysis. Springer-Verlag New York, 2016. https://ggplot2.tidyverse.org.
There are a few items available on the UWA LMS as learning materials for this session on using R + packages to make maps: two short videos, and two annotated pdfs based on
We suggest going through the first video first, and then trying the code, and finally viewing the second video and trying some different things. The material in the PowerPoint presentation on interpolation of point data is completely optional.
For activities today we suggest:Good luck!
(Currently on another Github site)
R
code to use georeferenced images (e.g. from NearMap) as map
backgrounds
Digitising map features from Google Earth, and R code for a function to convert Google Earth .kml files for use in R
Advanced
Spatial Analysis (spatial autocorrelation, variograms, and kriging
interpolations)
(Currently on UWA LMS, so you will need to be logged in)
CC-BY-SA • All content by Ratey-AtUWA. My employer does not necessarily know about or endorse the content of this website.
Created with rmarkdown in RStudio. Currently using the free yeti theme from Bootswatch.