Code
- 1
- Add a title
- 2
- Change the colours for the two survival lines
- 3
- Add a p-value annotation to the plot
- 4
- Add the risk table below the plot
- 5
- Set the risk table to be 20% of the plot height.
An desription of coding survival curves for use on a dark background.
Oncology, Data, Open Source, R, Sruvival
I don’t know if I’m actually going to use the results of this yet, but having spent more time than I’d hoped to trying to figure out how to do this, I thought I should document it. I’m producing a survival curve using ggsurvplot, but I want to present them on a dark background. That means I want to make my plot background transparent and make all the black stuff (lines, text etc) white so that it has good contrast on the dark background. I’ve almost never seen examples of this in the wild - either because in design terms someone says its a bad thing, /or/ its hard to do in R so people don’t try?
I’ve read enough data science blogs to know that having to move your eyes from a line to a legend to work out which one is which is also considered bad, and so I’m also going to label my lines.
My plots have risk tables on them which makes life all the more ‘exciting’. We will start with the official example plot - survival in the lung cancer data-set. I’ve made some tweaks to the standard off the shelf plot to get us started.
ggsurvplot works using ggplot and so you might expect that you can take its result and throw a few theme() statements at it and magically make things look different. Of course if it was that easy, I’d not be writing a blog about it! ggsurvplot knows this is an issue so it lets you define the theme /within/ the call to the plot. The reason this strange approach is used, is that ggsurvplot is actually creating two plots - the risk table is a plot as well.
So for simplicity, lets say I wanted to change the axis to be red and a bit thicker I can use this:
ggsurvplot(
fit,
data = lung,
title = "The official example Survival Curve",
palette = c("#E7B800", "#2E9FDF"),
pval = TRUE,
risk.table = TRUE,
tables.height = 0.2,
1 ggtheme = theme(
axis.line = element_line(colour = "red", linewidth=2),
axis.ticks = element_line(colour = "red", linewidth = 1.5),
axis.ticks.length=unit(.25, "cm")
)
)
This is all fairly well documented and straightforward so far. And we can add a transparent background using the theme_transparent() in the ggtheme statement.
ggsurvplot(
fit,
data = lung,
title = "The official example Survival Curve",
palette = c("#E7B800", "#2E9FDF"),
pval = TRUE,
risk.table = TRUE,
tables.height = 0.2,
1 ggtheme = theme_transparent() +
theme(
axis.line = element_line(colour = "red", linewidth=2),
axis.ticks = element_line(colour = "red", linewidth = 1.5),
axis.ticks.length=unit(.25, "cm")
)
)
If you’ve not already got dark mode enabled on the site, now might be a good time to enable it! You can use the slider at the top right of the screen. However, already there are a few things that even then aren’t behaving as you might expect! The title has vanished. The tick-marks (on the main plot) are in black not red and my decision to thicken the axis has suddenly made the risk table axis really obvious. You can add a tables.theme statement as well which would let you handle its axis differently. I’m also going to switch off the text labels with - tables.y.text = FALSE.
ggsurvplot(
fit,
data = lung,
title = "The official example Survival Curve",
palette = c("#E7B800", "#2E9FDF"),
pval = TRUE,
risk.table = TRUE,
tables.height = 0.2,
1 tables.y.text = FALSE,
ggtheme = theme_transparent() +
2 theme( text = element_text(colour = "yellow"),
axis.line = element_line(colour = "red", linewidth=2),
3 axis.ticks.x = element_line(colour = "red", linewidth = 1.5),
axis.ticks.y = element_line(colour = "red", linewidth = 1.5),
axis.ticks.length=unit(.25, "cm")
),
4 tables.theme = theme_cleantable()
)
You will notice we still have black text on the p value and the risk numbers and that we are still missing the title and the title on the risk table. The title is fairly easy to fix. The black text for the p-value is however not so simple. These bits of text are actually geom_text and the colour is specified when they are placed rather than using a theme. ggsurvplot has provided a method for the numbers at risk so we can use that.
ggsurvplot(
fit,
data = lung,
title = "The official example Survival Curve",
palette = c("#E7B800", "#2E9FDF"),
pval = TRUE,
risk.table = TRUE,
tables.height = 0.2,
tables.y.text = FALSE,
1 tables.col = "magenta",
ggtheme = theme_transparent() +
theme( text = element_text(colour = "yellow"),
2 plot.title = element_text(colour = "green", size = 30),
3 plot.tag=element_text(colour="orange"),
plot.subtitle = element_text(colour = "blue", size = 25),
axis.line = element_line(colour = "red", linewidth=2),
axis.ticks.x = element_line(colour = "red", linewidth = 1.5),
axis.ticks.y = element_line(colour = "red", linewidth = 1.5),
axis.ticks.length=unit(.25, "cm")
),
tables.theme = theme_cleantable()
)
If you’d like the Number at Risk to be smaller and on the left we can add it as a theme to tables.theme()
ggsurvplot(
fit,
data = lung,
title = "The official example Survival Curve",
palette = c("#E7B800", "#2E9FDF"),
pval = TRUE,
risk.table = TRUE,
tables.height = 0.2,
tables.y.text = FALSE,
tables.col = "magenta",
ggtheme = theme_transparent() +
theme( text = element_text(colour = "yellow"),
plot.title = element_text(colour = "green", size = 30),
plot.tag=element_text(colour="orange"),
plot.subtitle = element_text(colour = "blue", size = 25),
axis.line = element_line(colour = "red", linewidth=2),
axis.ticks.x = element_line(colour = "red", linewidth = 1.5),
axis.ticks.y = element_line(colour = "red", linewidth = 1.5),
axis.ticks.length=unit(.25, "cm")
),
tables.theme = theme_cleantable() +
theme(
1 plot.title = element_text(size = 20, hjust = 0 )
)
)
We’ve now done as much as we can achieve within ggsurvplot directly. So lets plot this all with white instead of all these hideous colours. We will save this as an object so that we can hack the p value colour.
par(bg = "#000000", fg = "#FFFFFF")
ggsurvplot(
fit,
data = lung,
title = "The official example Survival Curve",
palette = c("#E7B800", "#2E9FDF"),
pval = TRUE,
risk.table = TRUE,
tables.height = 0.2,
tables.y.text = FALSE,
1 tables.col = "white",
ggtheme = theme_transparent() +
theme( text = element_text(colour = "white"),
plot.title = element_text(colour = "white", size = 30),
plot.tag=element_text(colour="white"),
plot.subtitle = element_text(colour = "white", size = 25),
axis.line = element_line(colour = "white", linewidth=2),
axis.ticks.x = element_line(colour = "white", linewidth = 1.5),
axis.ticks.y = element_line(colour = "white", linewidth = 1.5),
axis.ticks.length=unit(.25, "cm")
),
tables.theme = theme_cleantable() +
theme(
plot.title = element_text(size = 20, hjust = 0 )
)
2 ) -> myPlot
3print(myPlot)
If we now examine the structure of myPlot with summary(myPlot) we get:
Length Class Mode
plot 11 gg list
table 11 gg list
data.survplot 10 data.frame list
data.survtable 10 data.frame list
Which tells us there is 4 parts to this object. Two data tables containing the data, and two gg class objects (plots in essence) once called ‘plot’ which has the survival curve, the other ‘table’ which has the risk table. Examining the plot will give us more information:
data: time, n.risk, n.event, n.censor, surv, std.err, upper, lower,
strata, sex [208x10]
mapping: x = ~time, y = ~surv
scales: y, ymin, ymax, yend, yintercept, ymin_final, ymax_final, lower, middle, upper, y0, colour, fill, x, xmin, xmax, xend, xintercept, xmin_final, xmax_final, xlower, xmiddle, xupper, x0
faceting: <ggproto object: Class FacetNull, Facet, gg>
compute_layout: function
draw_back: function
draw_front: function
draw_labels: function
draw_panels: function
finish_data: function
init_scales: function
map_data: function
params: list
setup_data: function
setup_params: function
shrink: TRUE
train_scales: function
vars: function
super: <ggproto object: Class FacetNull, Facet, gg>
-----------------------------------
mapping: colour = ~strata
geom_step: direction = hv, na.rm = FALSE
stat_identity: na.rm = FALSE
position_identity
mapping: x = ~x, y = ~y
geom_blank: na.rm = FALSE
stat_identity: na.rm = FALSE
position_identity
mapping: colour = ~strata
geom_point: na.rm = FALSE
stat_identity: na.rm = FALSE
position_identity
mapping: x = ~x, y = ~y
geom_text: na.rm = FALSE
stat_identity: na.rm = FALSE
position_identity
This shows there are 4 parts to this particular plot (your plots may vary!). Importantly the 4th part is geom_text - this likely contains our p-value information. These sit in something called a layer and we should look at the 4th Layer. We are interested in the aesthetics so we need to look at the aes_params:
$label
[1] "p = 0.0013"
$size
[1] 5
$hjust
[1] 0
$family
[1] ""
This confirms we are looking that p-value and shows that no colour has been specified. Adding a colour is as easy as:
I also wanted to edit the legend to annotate those alongside my lines rather than at the top of the window as a legend. We should therefore return to our ggsurvplot command to disable the legend:
ggsurvplot(
fit,
data = lung,
title = "The official example Survival Curve",
palette = c("#E7B800", "#2E9FDF"),
pval = TRUE,
1 legend = "none",
risk.table = TRUE,
tables.height = 0.2,
tables.y.text = FALSE,
tables.col = "white",
ggtheme = theme_transparent() +
theme( text = element_text(colour = "white"),
plot.title = element_text(colour = "white", size = 30),
plot.tag=element_text(colour="white"),
plot.subtitle = element_text(colour = "white", size = 25),
axis.line = element_line(colour = "white", linewidth=2),
axis.ticks.x = element_line(colour = "white", linewidth = 1.5),
axis.ticks.y = element_line(colour = "white", linewidth = 1.5),
axis.ticks.length=unit(.25, "cm")
),
tables.theme = theme_cleantable() +
theme(
plot.title = element_text(size = 20, hjust = 0 )
)
) -> myPlot
2myPlot$plot$layers[[4]]$aes_params$colour <- "white"
print(myPlot)
As myPlot$plot contains the survival curve we can use ggannotate to, erm, annotate it! Strata are ordered in the order of the factor for their grouping. So in our example at the very start we created a survival object with:
fit<- survfit(Surv(time, status) ~ sex, data = lung)
and ‘sex’ is where the strata comes from. Unhelpfully they are coded as sex = 1 or 2. I gather that the correct allocation is 1 = Male and 2 = Female. It would probably be better to fix this before we create the survival object. So lets go back and do that:
1require(forcats)
lung$sex |>
2 as.character() |>
3 fct_recode ( "Male" = "1", "Female" = "2") -> lung$sex
4fit<- survfit(Surv(time, status) ~ sex, data = lung)
5ggsurvplot(
fit,
data = lung,
title = "The official example Survival Curve",
palette = c("#E7B800", "#2E9FDF"),
pval = TRUE,
risk.table = TRUE,
tables.height = 0.2,
tables.y.text = FALSE
)
To annotate the graphs we will need to know:
The label colours is ‘easy’ we are already defining that with the line palette = c(“#E7B800”, “#2E9FDF”) in the plot and it would be sensible to use labels of the same colour as the line.
The label names is also ‘easy’ as we simply need to take the factor levels. These will be applied to the colours in order. So the first factor gets #E7B800 as a colour.
Positioning by code is harder. You may simply decide to do it using some hard entered numbers of the co-ordinates you want to use. But I thought this little bit of code might work:
1line_colours = c("#E7B800", "#2E9FDF")
2label_names = levels(lung$sex)
# Get the lower line name
3lower_line <- surv_median(fit)$strata[surv_median(fit)$median == min(surv_median(fit)$median, na.rm=T)]
upper_line <- surv_median(fit)$strata[surv_median(fit)$median == max(surv_median(fit)$median, na.rm=T)]
# establish 75% of x-axis
sum_fit <- summary(fit)
x75 <- max(sum_fit$time, na.rm=T) * 0.75
# get the lower line level at 75% of the way along the line
lower_line_y <- sum_fit$surv[sum_fit$time >= x75 & sum_fit$strata == lower_line][1]
# get the upper line level at 75% of the way along the line
upper_line_y <- sum_fit$surv[sum_fit$time >= x75 & sum_fit$strata == upper_line][1]
# annotate the lower line with the right hand to corner of the label at 75% & lower_line (with some padding)
4myPlot$plot <- myPlot$plot + annotate(
"text",
x= x75,
y = lower_line_y,
label= label_names[surv_median(fit)$strata ==lower_line],
colour=line_colours[surv_median(fit)$strata ==lower_line],
hjust = 1,
vjust = 1
)
# annotate the upper line above the line
myPlot$plot <- myPlot$plot + annotate(
"text",
x= x75,
y = upper_line_y,
label= label_names[surv_median(fit)$strata ==upper_line],
colour=line_colours[surv_median(fit)$strata ==upper_line],
hjust = 0,
vjust = -2 # this is a bit unpredictable
)
print(myPlot)
For completeness - we can create this all as a single piece of code, and we will replace the line colouration inf the ggsurvplot function so that it couldn’t be swapped around in error.
require(survival)
require(survminer)
require(forcats)
1data(lung)
lung$sex |>
as.character() |>
fct_recode ( "Male" = "1", "Female" = "2") -> lung$sex
fit<- survfit(Surv(time, status) ~ sex, data = lung)
line_colours = c("#E7B800", "#2E9FDF")
label_names = levels(lung$sex)
ggsurvplot(
fit,
data = lung,
title = "The official example Survival Curve",
2 palette = line_colours,
pval = TRUE,
legend = "none",
risk.table = TRUE,
tables.height = 0.2,
tables.y.text = FALSE,
tables.col = "white",
ggtheme = theme_transparent() +
theme( text = element_text(colour = "white"),
plot.title = element_text(colour = "white", size = 30),
plot.tag=element_text(colour="white"),
plot.subtitle = element_text(colour = "white", size = 25),
axis.line = element_line(colour = "white", linewidth=2),
axis.ticks.x = element_line(colour = "white", linewidth = 1.5),
3 axis.title.x = element_text(colour = "white", hjust=1),
axis.title.y = element_text(colour = "white", hjust=1),
axis.ticks.y = element_line(colour = "white", linewidth = 1.5),
axis.ticks.length=unit(.25, "cm")
),
tables.theme = theme_cleantable() +
theme(
plot.title = element_text(size = 20, hjust = 0 )
)
) -> myPlot
# change the p-value colour
myPlot$plot$layers[[4]]$aes_params$colour <- "white"
# Get the lower line name
lower_line <- surv_median(fit)$strata[surv_median(fit)$median == min(surv_median(fit)$median, na.rm=T)]
upper_line <- surv_median(fit)$strata[surv_median(fit)$median == max(surv_median(fit)$median, na.rm=T)]
# establish 75% of x-axis
sum_fit <- summary(fit)
x75 <- max(sum_fit$time, na.rm=T) * 0.75
# get the lower line level at 75% of the way along the line
lower_line_y <- sum_fit$surv[sum_fit$time >= x75 & sum_fit$strata == lower_line][1]
# get the upper line level at 75% of the way along the line
upper_line_y <- sum_fit$surv[sum_fit$time >= x75 & sum_fit$strata == upper_line][1]
# annotate the lower line with the right hand to corner of the label at 75% & lower_line (with some padding)
myPlot$plot <- myPlot$plot + annotate(
"text",
x= x75,
y = lower_line_y,
label= label_names[surv_median(fit)$strata ==lower_line],
colour=line_colours[surv_median(fit)$strata ==lower_line],
hjust = 1,
vjust = 1
)
# annotate the upper line above the line
myPlot$plot <- myPlot$plot + annotate(
"text",
x= x75,
y = upper_line_y,
label= label_names[surv_median(fit)$strata ==upper_line],
colour=line_colours[surv_median(fit)$strata ==upper_line],
hjust = 0,
vjust = -2 # this is a bit unpredictable
)
print(myPlot)
Finally, if we want to save the plot as a vector or png, we need to tell ggsave not to apply a background colour!
@misc{polwart2025,
author = {Polwart, Calum},
title = {White {Axes,} Line Labels and Transparency in {Survival}
{Curves}},
date = {2025-05-11},
url = {https://www.chemo.org.uk/posts/TransparentSurvivalCurves.html},
langid = {en}
}