I'm doing a comparison chart of two different estimates of the same time series data. I'm filling the area between the two series in green if the original estimate is more than the latest estimate, and red otherwise.
I've got that part working, but I'd like to add a legend for the fill colors. I tried scale_fill_manual towards the bottom of the code, but it doesn't seem to be doing anything?
Here's the code:
library(ggplot2)
library(scales)
library(colorspace)
# Return a polygon that only plots between yLower and yUpper when yLower is
# less than yUpper.
getLowerPolygon = function(x, yLower, yUpper) {
# Create the table of coordinates
poly = data.frame(
x = numeric(),
y = numeric())
lastReversed = (yUpper[1] < yLower[1])
for (r in 1:length(x)) {
reversed = (yUpper[r] < yLower[r])
if (reversed != lastReversed) {
# Between points r-1 and r, the series intersected, so we need to
# change the polygon from visible to invisible or v.v. In either
# case, just add the intersection between those two segments to the
# polygon. Algorithm from:
# https://en.wikipedia.org/wiki/Line-line_intersection
# First line: x1,y1 - x2,y2
x1 = x[r-1]
y1 = yLower[r-1]
x2 = x[r]
y2 = yLower[r]
# Second line: x3,y3 - x4,y4
x3 = x[r-1]
y3 = yUpper[r-1]
x4 = x[r]
y4 = yUpper[r]
# Calculate determinants
xy12 = det(matrix(c(x1, y1, x2, y2), ncol = 2))
xy34 = det(matrix(c(x3, y3, x4, y4), ncol = 2))
x12 = det(matrix(c(x1, 1, x2, 1), ncol = 2))
x34 = det(matrix(c(x3, 1, x4, 1), ncol = 2))
y12 = det(matrix(c(y1, 1, y2, 1), ncol = 2))
y34 = det(matrix(c(y3, 1, y4, 1), ncol = 2))
# Calculate fraction pieces
xn = det(matrix(c(xy12, x12, xy34, x34), ncol = 2))
yn = det(matrix(c(xy12, y12, xy34, y34), ncol = 2))
d = det(matrix(c(x12 , y12, x34, y34), ncol = 2))
# Calculate intersection
xi = xn / d
yi = yn / d
# Add the point
poly[nrow(poly)+1,] = c(xi, yi)
}
lastReversed = reversed
# http://stackoverflow.com/questions/2563824
poly[nrow(poly)+1,] = c(x[r], min(yLower[r], yUpper[r]))
}
poly = rbind(poly, data.frame(
x = rev(x),
y = rev(yUpper)))
return(poly)
}
getComparisonPlot = function(data, title, lower_name, upper_name,
x_label, y_label, legend_title = '') {
lightGreen = '#b0dd8d'
lightRed = '#fdba9a'
darkGray = RGB(.8, .8, .8)
midGray = RGB(.5, .5, .5)
plot = ggplot(data, aes(x = x))
plot = plot + geom_polygon(
aes(x = x, y = y),
data = data.frame(
x = c(data$x, rev(data$x)),
y = c(data$yLower, rev(data$yUpper))
),
fill = lightRed)
coords = getLowerPolygon(data$x, data$yLower, data$yUpper)
plot = plot + geom_polygon(
aes(x = x, y = y),
data = coords,
fill = lightGreen)
plot = plot + geom_line(
aes(y = yUpper, color = 'upper'),
size = 0.5)
plot = plot + geom_line(
aes(y = yLower, color = 'lower'),
size = 0.5)
plot = plot +
ggtitle(paste(title, '\n', sep='')) +
xlab(x_label) +
ylab(y_label) +
scale_y_continuous(labels = comma)
# http://stackoverflow.com/a/10355844/106302
plot = plot + scale_color_manual(
name = legend_title,
breaks = c('upper' , 'lower'),
values = c('gray20', 'gray50'),
labels = c(upper_name, lower_name))
plot = plot + scale_fill_manual(
name = 'Margin',
breaks = c('upper', 'lower'),
values = c(lightGreen, lightRed),
labels = c('Over', 'Under'))
return(plot)
}
print(getComparisonPlot(
data = data.frame(
x = 1:20,
yLower = 1:20 %% 5 + 2,
yUpper = 1:20 %% 7
),
title = 'Comparison Chart',
lower_name = 'Latest',
upper_name = 'Original',
x_label = 'X axis',
y_label = 'Y axis',
legend_title = 'Thing'
))
Here's an image of the chart, I think it is a cool technique:
I'm also open to any other suggestions for improving my ggplot code.
GGplot need you to map polygons fill aesthetic to some variable. OR, in this case, it need just you to "label" the types of polygons (i.e. 'upper' and 'lower'). You do this by passing a string with the respective label for the fill aesthetic of geom_polygon(). What you are doing is passing a giving colour for each polygon and not mapping to anything that the ggplot will understand. It's kind of a "hard coded colour" =P.
Well, here are the changes inside getComparisonPlot:
plot = plot + geom_polygon(
aes(x = x, y = y, fill = "upper"),
data = coords)
plot = plot + geom_polygon(
aes(x = x, y = y, fill = "lower"),
data = data.frame(
x = c(data$x, rev(data$x)),
y = c(data$yLower, rev(data$yUpper))
))
One more thing. Note that the strings passed to fill aesthetic coincides with the breaks passed to the scale_fill_manual. It is necessary to make the legend map things right.
plot = plot + scale_fill_manual(
name = 'Margin',
breaks = c('upper', 'lower'), # <<< corresponds to fill aesthetic labels
values = c(lightGreen, lightRed),
labels = c('Over', 'Under'))
Result:
hope it helps.
Related
I am working on plotting a voronoi point between two coordinates. So that the point aligns with respective to the lower and upper values in the dataframe.
Unfortunately, when I plot the data, it linearly increases by the value of the points. This then produces the following plot:
Here's the script that I used:
library(ggplot2)
library(ggvoronoi)
ggplot(voronoi_data, aes(x=lower, y=upper)) + stat_voronoi() +geom_point(aes(fit))
ggplot(voronoi_data, aes(x = lower, y = upper)) +
stat_voronoi(
geom = "path",
color = 4,
lwd = 0.7,
linetype = 1
) + geom_point(aes(fit))
Some points do not lie within the voronoi diagram because they are either too large or too small. Therefore, I have thought of incrementing the upper and lower value by fit, and layering two voronoi over one another with two different colour schemes and then plotting the fit values (this is currently under process as I just had this idea.)
The update on the progress idea:
voronoi_1 <-
mapply(function(a, b)
a - b, voronoi_data$lower, voronoi_data$fit) %>% data.frame(lower_add =
.)
voronoi_1 <-
rbind(
voronoi_1,
mapply(function(a, b)
a + b, voronoi_data$lower, voronoi_data$fit) %>% data.frame(lower_add =
.)
)
voronoi_2 <-
mapply(function(a, b)
a + b, voronoi_data$upper, voronoi_data$fit) %>% data.frame(upper_add =
.)
voronoi_2 <-
rbind(
voronoi_2,
mapply(function(a, b)
a - b, voronoi_data$upper, voronoi_data$fit) %>% data.frame(upper_add =
.)
)
voronoi_3 <- rep(voronoi_data$fit, 2) %>% data.frame(fit = .)
voronoi_update <- cbind(voronoi_1, voronoi_2, voronoi_3)
ggplot(voronoi_data, aes(x = lower, y = upper)) +
stat_voronoi(
geom = "path",
color = 4,
lwd = 0.7,
linetype = 1
) + geom_point(aes(fit), col = 'blue') + stat_voronoi(
data = voronoi_update,
aes(x = lower_add, y = upper_add),
geom = "path",
color = 2,
lwd = 0.7,
linetype = 1
) + geom_point(col = 'green') + geom_segment(aes(
x = lower,
y = upper,
xend = fit,
yend = fit
))
Update produces the following picture:
How can I get the green-dots to connect with the blue-dots?
reproducible data:
structure(list(lower = c(-50.231394143356, -56.2551026846824,
28.4249214917657, -72.7725910398994, -81.2658846682781, 21.6407972918016,
-6.38857800084765, -83.9469403037355, -7.49345446155375, -9.25035611734441
), upper = c(83.3536041213786, 63.9866816320508, 145.329559457229,
48.0937531194102, 42.462115738722, 136.959651947817, 100.752432092854,
40.2250494139988, 110.279218627158, 107.6295802627), fit = c(16.5611049890113,
3.86578947368421, 86.8772404744972, -12.3394189602446, -19.4018844647781,
79.3002246198091, 47.1819270460033, -21.8609454448684, 51.3928820828022,
49.1896120726776)), class = "data.frame", row.names = c(NA, -10L
))
The reason why your points aren't lying in the Voronoi is that you have put aes(fit) inside geom_point. This is interpreted as the x axis value for the points, which presumably isn't what you want:
ggplot(voronoi_data, aes(x = lower, y = upper)) +
stat_voronoi(
geom = "path",
color = 4,
lwd = 0.7,
linetype = 1
) + geom_point()
I have continuous data that I'd like to plot using R's plotly with a box or violin plot without the outliers and whiskers:
set.seed(1)
df <- data.frame(group=c(rep("g1",500),rep("g2",700),rep("g3",600)),
value=c(c(rep(0,490),runif(10,10,15)),abs(rnorm(700,1,10)),c(rep(0,590),runif(10,10,15))),
stringsAsFactors = F)
df$group <- factor(df$group, levels = c("g1","g2","g3"))
I know how to remove outliers in plotly:
plotly::plot_ly(x = df$group, y =df$value, type = 'box', color = df$group, boxpoints = F, showlegend = F)
But I'm still left with the whiskers.
I tried using ggplot2 for that (also limiting the height of the y-axis to that of the 75 percentile):
library(ggplot2)
gp <- ggplot(df, aes(group, value, color = group, fill = group)) + geom_boxplot(outlier.shape = NA, coef = 0) +
scale_y_continuous(limits = c(0, ceiling(max(dplyr::summarise(dplyr::group_by(df, group), tile = quantile(value, probs = 0.75))$tile)))) +
theme_minimal() + theme(legend.position = "none",axis.title = element_blank())
But then trying to convert that to a plotly object doesn't maintain that:
plotly::ggplotly(gp)
Any idea?
This is a workaround.
I changed your plot a bit, first.
# box without outliers
p <- plot_ly(df, x = ~group, y = ~value, type = 'box',
color = ~group, boxpoints = F, showlegend = F,
whiskerwidth = 0, line = list(width = 0)) # no whisker, max or min line
Then I add the medians back to the graph. This requires calculating the medians, matching the colors, and creating the shape lists for Plotly.
For the colors, it's odd, the first three default colors are used, but the order is g3, g2, g1...
# the medians
res = df %>% group_by(group) %>%
summarise(med = median(value))
# default color list: https://community.plotly.com/t/plotly-colours-list/11730/2
col = rev(c('#1f77b4', '#ff7f0e', '#2ca02c')) # the plot is colored 3, 2, 1
# discrete x-axis; domain default [0, 1]
# default box margin = .08, three groups, each get 1/3 of space
details <- function(col){ # need everytime basics
list(type = 'line',
line = list(color = col, width = 4),
xref = "paper", yref = "y")
}
# horizontal segments/ median
segs = lapply(1:nrow(res),
function(k){
x1 <- k/3 - .08 # if the domain is [0, 1]
x0 <- (k - 1)/3 + .08
y0 <- y1 <- res[k, ]$med
line = list("x0" = x0, "x1" = x1,
"y0" = y0, "y1" = y1)
deets = details(col[k])
c(deets, line)
})
Finally, I added them back onto the plot.
p %>% layout(shapes = segs)
I made the lines obnoxiously wide, but you get the idea.
If you wanted the IQR outline back, you could do this, as well. I used functions here, as well. I figured that the data you've provided is not the actual data, so the function will serve a purpose.
# include IQR outline
res2 = df %>% group_by(group) %>%
summarise(q1 = setNames(quantile(value, type = 7, 1/4), NULL),
q3 = setNames(quantile(value, type = 7, 3/4), NULL),
med = median(value))
# IQR segments
rects = lapply(1:nrow(res2), # if the domain is [0, 1]
function(k){
x1 <- k/3 - .08
x0 <- (k - 1)/3 + .08
y0 <- res2[k, ]$q1
y1 <- res2[k, ]$q3
line = list(color = col[k], width = 4)
rect = list("x0" = x0, "x1" = x1,
"y0" = y0, "y1" = y1,
type = "rect", xref = "paper",
yref = "y", "line" = line)
rect
})
rects = append(segs, rects)
p %>% layout(shapes = rects)
The purpose of the code is to produce an interactive plotly chart with shaded vertical areas on specified subsets on X-axis.
The first step is to construct a ggplot2 object, with shaded vertical areas constructed using geom_rect, then use ggplotly to produce a plotly object.
Since ggplotly does not produce an output which contains the shaded vertical areas anymore, I am adding them to ggplotly output (which is is a plotly object) by using plotly function add_lines.
However, this approach does not work. The approach that works is to start from a natively-built plotly object and then using plotly function add_lines.
Does this mean that output from ggplotly is not a full-featured plotly object?
The reproducible example is below. One can change values of logical variables useOnlyPlotly (line 67) and useGeomRect (line 66) to see the behaviors described above
require(tidyverse)
require(plotly)
require(lubridate)
plotShadedAreaUsingGeomBarsFunc <- function(colorArea, dataY){
ggplot2::geom_bar(data = trimmedRecessionsDates, inherit.aes = FALSE,
aes_(x = quote(MidPoint), y = base::max(dataY)), # y = Inf doesn't work
stat = "identity",width = 0.1,
# position = "stack",
fill = colorArea, alpha = 0.2)
}
plotShadedAreaUsingGeomRectFunc <- function(colorArea, dataY){
ggplot2::geom_rect(data = trimmedRecessionsDates, inherit.aes = FALSE,
aes(xmin = as.Date(Peak), xmax = as.Date(Trough), ymin = -Inf, ymax = +Inf),
fill = colorArea,
alpha = 0.2)
}
# dates
dateOne <- lubridate::ymd("2000-1-1")
dateTwo <- lubridate::ymd("2004-1-1")
dateThree <- lubridate::ymd("2009-1-1")
dateFour <- lubridate::ymd("2013-1-1")
dateFive <- lubridate::ymd("2017-12-31")
PeakDates <- c(lubridate::ymd("2001-03-01"), lubridate::ymd("2007-12-01"))
TroughDates <- c(lubridate::ymd("2001-11-01"), lubridate::ymd("2008-08-31"))
sequenceDates <- seq(dateOne, dateFive, by="month")
sequenceInRecession <- c(rep(0,length(sequenceDates)))
sequenceInRecession <- base::replace(sequenceInRecession, list = c(15,16,17,18,19,20,21,22,23,96,97,98,99,100), values = c(rep(1,14)))
sequenceInRecession <- base::replace(sequenceInRecession, list = c(101,102,103,104,105,106,107,108,109,110,111,112,113,114), values = c(rep(1,14)))
dataFrameRecessionDates <- data.frame(Dates = sequenceDates, InRecession = sequenceInRecession)
dataFrameRecessionDates$Dates <- lubridate::as_date(dataFrameRecessionDates$Dates)
#data
theDataFrame <- data.frame(Dates = c(dateOne, dateTwo, dateThree, dateFour, dateFive), SomeValues = c(0.2, 2.8, 4.5, 9.8, -0.3),
season = c("SeasOne","SeasTwo","SeasOne","SeasOne","SeasTwo"))
trimmedRecessionsDates <- data.frame(Peak = PeakDates, Trough = TroughDates)
# define midPoint as middle point between Peak and Trough
trimmedRecessionsDates$MidPoint = trimmedRecessionsDates$Peak + floor((trimmedRecessionsDates$Trough - trimmedRecessionsDates$Peak)/2)
trimmedRecessionsDates$MidPoint <- base::as.Date(trimmedRecessionsDates$MidPoint)
colNamesDataFrame <- colnames(theDataFrame)[2:2]
valMax <- base::max(sapply(theDataFrame[colNamesDataFrame], max, na.rm = TRUE))
valMin <- base::min(sapply(theDataFrame[colNamesDataFrame], min, na.rm = TRUE))
dataFrameRecessionDates$InRecession[dataFrameRecessionDates$InRecession %in% 1] <- valMax + 0.2*base::abs(valMax)
dataFrameRecessionDates$InRecession[dataFrameRecessionDates$InRecession %in% 0] <- valMin - 0.2*base::abs(valMin)
ggplotObjUsingGeomBar <- ggplot2::ggplot(data = theDataFrame, aes(x = Dates, y = SomeValues, color = season)) +
ggplot2::geom_line() +
plotShadedAreaUsingGeomBarsFunc('turquoise3', theDataFrame$SomeValues)
ggplotObjUsingGeomRect <- ggplot2::ggplot(data = theDataFrame, aes(x = Dates, y = SomeValues)) +
ggplot2::geom_line() +
plotShadedAreaUsingGeomRectFunc('turquoise3', theDataFrame$SomeValues)+
ggplot2::theme_bw()
useGeomRect = TRUE
useOnlyPlotly = TRUE
thePlotlyObjToAnalyze <- plot_ly()
if (useOnlyPlotly)
{
thePlotlyObjToAnalyze <- plot_ly(data = theDataFrame, x = ~Dates, y = ~SomeValues) %>%
add_lines(data = theDataFrame, x = ~Dates, y = ~SomeValues,
line = list(width = 3), hoverinfo = "x + y")
} else {
if (useGeomRect)
{
thePlotlyObjToAnalyze <- hide_legend(ggplotly(ggplotObjUsingGeomRect))
} else {
thePlotlyObjToAnalyze <- hide_legend(ggplotly(ggplotObjUsingGeomBar))
}
}
(thePlotlyObjToAnalyze %>%
plotly::add_lines(data = dataFrameRecessionDates,
x = ~Dates, y = ~InRecession,
line = list(width = 0),
fill = "tozerox",
fillcolor = "rgba(64, 64, 64, 0.3)",
showlegend = F,
hoverinfo = "none"))
Update: Below is code based on answer provided in enter link description here, but unfortunately it did not work for me
library(plotly)
library(ggplot2)
useOnlyPlotly <- FALSE
thePlot <- plot_ly()
if (useOnlyPlotly)
{
thePlot <- plot_ly() %>%
add_trace(data = economics, x = ~date, y = ~unemploy, type="scatter", mode = "lines")
}else{
theGgplot2Obj <- ggplot(data = economics, aes(x = date, y = unemploy)) + geom_line()
thePlot <- ggplotly(theGgplot2Obj)
thePlot[['x']][['layout']][['shapes']] <- c()
}
( thePlot <- layout(thePlot,
shapes = list(
list(type = "rect",
fillcolor = "blue", line = list(color = "blue"), opacity = 0.5,
x0 = "1980-01-01", x1 = "1990-01-01",
y0 = 6000, y1 = 8000
)
)
)
)
Your idea of using add_lines combined with filltozero is good but the gaps between your shades will be problematic, you would probably need to add NaN in between to get it right.
The real problem is that your input dates are strings and Plotly stores the dates as integers (milliseconds since the epoch). So we would need to convert the dates first and then plot them.
x0 = as.integer(as.POSIXct(trimmedRecessionsDates$Peak[[i]])) * 1000
thePlotlyObjToAnalyze$x$layout$shape <- c()
shapes = list()
for (i in 1:length(trimmedRecessionsDates$MidPoint)) {
shapes[[i]] = list(type = "rect",
fillcolor = "blue", line = list(color = "blue"), opacity = 0.5,
x0 = as.integer(as.POSIXct(trimmedRecessionsDates$Peak[[i]])) * 1000,
x1 = as.integer(as.POSIXct(trimmedRecessionsDates$Trough[[i]])) * 1000,
y0 = 0,
y1 = 1,
yref = 'paper'
)
}
thePlotlyObjToAnalyze <- layout(thePlotlyObjToAnalyze,
shapes = shapes
)
I am using ggplot and I was able to get the plot that I want.
But when I tried to add a legend, something went wrong. The legend has different shapes, sizes and linetypes; the only correct match is the color.
Here is the code, with simulated data:
library(ggplot)
set.seed(5703)
# DATA 1
x1 <- seq(1, 100)
y1 <- rnorm(mean = 50, sd = 10, length(x1))
df1 <- data.frame(x1, y1)
head(df1)
# DATA 2
x2 <- seq(1, 100, 5)
y2 <- rnorm(mean = 50, sd = 2, length(x2))
df2 <- data.frame(x2, y2)
head(df2)
# Plot: DATA 1 and DATA 2
p101 <- ggplot (df1, aes( x = x1, y = y1) ) +
geom_point(aes(color="Vals every 1sec - shape circle"), shape = 1, size = 4 ) +
geom_line (aes(color="Vals every 1sec - shape circle"), size = 0.5, linetype= "dotdash") +
geom_point(data= df2, aes(x = x2, y = y2, color="Vals every 5sec - shape: triangle & bigger, line: thicker"), shape= 2, size= 6 ) +
geom_line (data= df2, aes(x = x2, y = y2, color="Vals every 5sec - shape: triangle & bigger, line: thicker"), size = 1.25, linetype = "solid" ) +
scale_colour_manual("", values=c("Vals every 1sec - shape circle" = "#e66101",
"Vals every 5sec - shape: triangle & bigger, line: thicker" = "#5e3c99" ) )+
theme(legend.position = c(0.7,0.1) )+
labs (title = "Graph Nr. 101", x = "Time [s]", y = "Values")
p101
# legend is mixed up, it is not showing the correct shapes and sizes for each data
Here is the image:
You will notice that both items on the legend have a circle and a triangle, same size and linetype.
Maybe the plot code is entirely wrong, so I am open to any suggestions and ready to learn :)
You will have to add in the legend and theme changes but this should get you to where you want.
library(ggplot)
library(dplyr)
set.seed(5703)
# DATA 1
x1 <- seq(1, 100)
y1 <- rnorm(mean = 50, sd = 10, length(x1))
df1 <- data.frame(x = x1, y = y1, group = "A")
head(df1)
# DATA 2
x2 <- seq(1, 100, 5)
y2 <- rnorm(mean = 50, sd = 2, length(x2))
df2 <- data.frame(x = x2, y = y2, group = "B")
head(df2)
df <- bind_rows(df1, df2)
# Plot: DATA 1 and DATA 2
p101 <- ggplot (df, aes( x = x, y = y, color = group) ) +
geom_line(aes(linetype = group), size = 0.5) +
geom_point(aes(shape = group), size = 4 )
I'm trying to plot 2 sets of data points and a single line in R using ggplot.
The issue I'm having is with the legend.
As can be seen in the attached image, the legend applies the lines to all 3 data sets even though only one of them is plotted with a line.
I have melted the data into one long frame, but this still requires me to filter the data sets for each individual call to geom_line() and geom_path().
I want to graph the melted data, plotting a line based on one data set, and points on the remaining two, with a complete legend.
Here is the sample script I wrote to produce the plot:
xseq <- 1:100
x <- rnorm(n = 100, mean = 0.5, sd = 2)
x2 <- rnorm(n = 100, mean = 1, sd = 0.5)
x.lm <- lm(formula = x ~ xseq)
x.fit <- predict(x.lm, newdata = data.frame(xseq = 1:100), type = "response", se.fit = TRUE)
my_data <- data.frame(x = xseq, ypoints = x, ylines = x.fit$fit, ypoints2 = x2)
## Now try and plot it
melted_data <- melt(data = my_data, id.vars = "x")
p <- ggplot(data = melted_data, aes(x = x, y = value, color = variable, shape = variable, linetype = variable)) +
geom_point(data = filter(melted_data, variable == "ypoints")) +
geom_point(data = filter(melted_data, variable == "ypoints2")) +
geom_path(data = filter(melted_data, variable == "ylines"))
pushViewport(viewport(layout = grid.layout(1, 1))) # One on top of the other
print(p, vp = viewport(layout.pos.row = 1, layout.pos.col = 1))
You can set them manually like this:
We set linetype = "solid" for the first item and "blank" for others (no line).
Similarly for first item we set no shape (NA) and for others we will set whatever shape we need (I just put 7 and 8 there for an example). See e.g. http://www.r-bloggers.com/how-to-remember-point-shape-codes-in-r/ to help you to choose correct shapes for your needs.
If you are happy with dots then you can use my_shapes = c(NA,16,16) and scale_shape_manual(...) is not needed.
my_shapes = c(NA,7,8)
ggplot(data = melted_data, aes(x = x, y = value, color=variable, shape=variable )) +
geom_path(data = filter(melted_data, variable == "ylines") ) +
geom_point(data = filter(melted_data, variable %in% c("ypoints", "ypoints2"))) +
scale_colour_manual(values = c("red", "green", "blue"),
guide = guide_legend(override.aes = list(
linetype = c("solid", "blank","blank"),
shape = my_shapes))) +
scale_shape_manual(values = my_shapes)
But I am very curious if there is some more automated way. Hopefully someone can post better answer.
This post relied quite heavily on this answer: ggplot2: Different legend symbols for points and lines