The OECD publishes yearly analytical house price indicators for its members and partner countries: an index of rent prices, an index of house prices, and two affordability ratios — price-to-income and price-to-rent. The OECD gives them as indices with 2015 = 100; here every value is turned into a plain percentage change since 2015 — “+13%” means rents are 13% higher than in 2015. They show how much each market moved, not the level of rents in euros.
This vignette pulls the series straight from the OECD data API and builds the same kind of visualisations as the EU Salary Race: a ranked “bar chart race” and a shareable GIF — with Greece highlighted in red against the rest of Europe in gray. It starts with a look at Greece on its own, since rents and house prices there have taken very different paths.
# install.packages(c("dplyr", "tidyr", "countrycode", "plotly",
# "gganimate", "gifski", "scales", "ragg"))
library(dplyr)
library(tidyr)
library(countrycode) # ISO3 codes -> country names
library(plotly) # interactive charts
library(gganimate) # animation
library(gifski) # GIF renderer
library(scales) # axis formattingThe OECD serves its datasets through an SDMX API that can answer with
a plain CSV, so read.csv() is all we need — no API key, no
extra package. The dataset is Analytical house prices
indicators (DSD_AN_HOUSE_PRICES@DF_HOUSE_PRICES). We
keep the annual observations of the 29 European
countries the OECD covers (EU members, Norway, Switzerland, Iceland and
the United Kingdom).
url <- paste0("https://sdmx.oecd.org/public/rest/data/",
"OECD.ECO.MPD,DSD_AN_HOUSE_PRICES@DF_HOUSE_PRICES,1.0/all",
"?startPeriod=2000&dimensionAtObservation=AllDimensions",
"&format=csvfilewithlabels")
df_raw <- read.csv(url, fileEncoding = "UTF-8-BOM", check.names = FALSE)
europe <- c("AUT", "BEL", "BGR", "HRV", "CZE", "DNK", "EST", "FIN", "FRA", "DEU",
"GRC", "HUN", "IRL", "ITA", "LVA", "LTU", "LUX", "NLD", "POL", "PRT",
"ROU", "SVK", "SVN", "ESP", "SWE", "NOR", "CHE", "ISL", "GBR")
df_housing <- df_raw %>%
filter(FREQ == "A", REF_AREA %in% europe,
MEASURE %in% c("RPI", "HPI", "HPI_YDH", "HPI_RPI")) %>%
transmute(iso = REF_AREA,
country = countrycode(REF_AREA, origin = "iso3c", destination = "country.name"),
measure = MEASURE,
year = as.integer(TIME_PERIOD),
values = round(as.numeric(OBS_VALUE), 1),
change = round(values - 100, 1)) %>% # % change since 2015
filter(!is.na(values))
head(df_housing)## iso country measure year values change
## 1 IRL Ireland HPI_YDH 2000 123.6 23.6
## 2 IRL Ireland HPI 2000 79.5 -20.5
## 3 IRL Ireland HPI_YDH 2001 122.8 22.8
## 4 IRL Ireland HPI 2001 89.3 -10.7
## 5 IRL Ireland HPI_YDH 2002 124.5 24.5
## 6 IRL Ireland HPI_YDH 2003 134.8 34.8
Each row is one country, one indicator, one year:
| Column | Description |
|---|---|
country |
Country name (derived from the OECD’s ISO3 REF_AREA
code) |
measure |
RPI rent prices, HPI nominal house prices,
HPI_YDH price-to-income ratio, HPI_RPI
price-to-rent ratio |
year |
Year of observation |
values |
Index, 2015 = 100 (as published) |
change |
Percentage change since 2015 (values - 100) |
For the races we need one indicator at a time, from 2015 (where every country starts at 0%) onwards, ranked within each year, with Greece in red. A small helper does that for any of the four measures:
race_data <- function(df, which, highlight = "Greece", from = 2015) {
d <- df[df$measure == which & df$year >= from, ]
d$rank <- ave(-d$change, d$year, FUN = function(v) rank(v, ties.method = "first"))
d$color <- ifelse(d$country %in% highlight, "#E74C3C", "gray70")
d
}
df_rent <- race_data(df_housing, "RPI") # rent prices
df_income <- race_data(df_housing, "HPI_YDH") # house price to income
title_rent <- "How much more expensive rents got since 2015 (%)"
# "+13%" / "-3%"; rounding first (+ 0 turns -0 into 0) so -0.4 reads "+0%", never "-0%"
pct <- function(v) sprintf("%+.0f%%", round(v) + 0)
title_income <- "How much faster house prices grew than incomes since 2015 (%)"
# data source as a footnote inside each plotly chart (it stays on the chart
# when it is downloaded as a PNG from the plotly toolbar): `b` is the bottom
# margin that makes room for it; the note sits at the bottom of that margin,
# in pixels, so it lands in the same place whatever the chart's height
oecd_url <- paste0("https://data-explorer.oecd.org/vis?df[ds]=dsDisseminateFinalDMZ",
"&df[id]=DSD_AN_HOUSE_PRICES%40DF_HOUSE_PRICES&df[ag]=OECD.ECO.MPD")
add_source <- function(p, what, b) {
plotly::layout(p,
margin = list(b = b),
annotations = list(list(
text = paste0("Source: <a href='", oecd_url, "'>OECD, Analytical house prices indicators</a>",
" (", what, "), annual; 2015 = 100 index shown as % change since 2015"),
xref = "paper", yref = "paper", x = 1, y = 0, yshift = -(b - 22),
xanchor = "right", yanchor = "top", showarrow = FALSE,
font = list(size = 11, color = "gray50"))))
}Before racing Greece against Europe, it helps to see its three indicators side by side, as a change against 2015 (the dotted zero line). Rents and house prices both fell hard after 2010, but they recovered at very different speeds — and because incomes recovered more slowly still, the price-to-income line shows how much less affordable buying a home has become.
labels_measure <- c(RPI = "Rent prices", HPI = "House prices", HPI_YDH = "Price to income")
df_greece <- df_housing %>%
filter(country == "Greece", measure %in% names(labels_measure)) %>%
mutate(indicator = labels_measure[measure])
df_greece_temp<-df_greece[order(df_greece$measure, df_greece$year), c("year", "indicator", "change")]
plot_ly(df_greece_temp,
x = ~year,
y = ~change,
color = ~indicator,
colors = c("#0082c8", "#E74C3C", "#3cb44b"),
type = "scatter",
mode = "lines+markers",
text = ~paste0("Year: ", year, "<br>", indicator, ": ", sprintf("%+.1f%%", change)),
hoverinfo = "text",
marker = list(size = 8)) %>%
plotly::layout(title = "Greece: change since 2015 (%)",
xaxis = list(title = "Year", tickangle = -90),
yaxis = list(title = "% vs 2015", ticksuffix = "%"),
legend = list(orientation = "h", y = -0.2),
font = list(size = 16, color = "gray25"),
shapes = list(list(type = "line", x0 = min(df_greece$year), x1 = max(df_greece$year),
y0 = 0, y1 = 0, line = list(dash = "dot", color = "gray")))) %>%
add_source("rent prices RPI, house prices HPI, price-to-income HPI_YDH", b = 190)The frame argument of plot_ly turns a bar
chart into a play/pause animation stepping through each year, and
plotting by rank instead of country name makes the bars
physically reorder every year. Greece is layered on top in red so it is
always visible against the gray field. The same function draws both
races: rents, and house prices relative to income.
bar_race <- function(d, title, what) {
plot_ly() %>%
add_bars(data = d[d$color == "gray70", ],
x = ~rank, y = ~change, frame = ~year, ids = ~rank,
marker = list(color = "gray70"),
text = ~paste0(country, " ", sprintf("%+.0f%%", change)), hoverinfo = "text",
textposition = "outside", textangle = 90, showlegend = FALSE) %>%
add_bars(data = d[d$color %in% "#E74C3C", ],
x = ~rank, y = ~change, frame = ~year, ids = ~rank,
marker = list(color = "#E74C3C"),
text = ~paste0(country, " ", sprintf("%+.0f%%", change)), hoverinfo = "text",
textposition = "outside", textangle = 90, showlegend = FALSE) %>%
layout(xaxis = list(title = "Rank", tickmode = "array"),
yaxis = list(title = "% vs 2015", ticksuffix = "%"),
title = title,
margin = list(l = 50, r = 50, b = 200, t = 100),
font = list(size = 20, color = "gray25", weight = "bold")) %>%
animation_opts(frame = 1000, transition = 0, easing = "linear", redraw = TRUE) %>%
add_source(what, b = 330)
}
bar_race(df_rent, title_rent, "rent prices, RPI")gganimateA rendered GIF is easier to share than an interactive chart.
ggplot2 + gganimate recreates the ranked rent
race as a horizontal bar chart with a dark background, country labels
and a large year watermark.
n_countries <- max(df_rent$rank)
hi <- max(df_rent$change)
lo <- min(0, min(df_rent$change)) # a few markets were below 2015 for a while
pad <- (hi - lo) * 0.02
p <- ggplot(df_rent, aes(x = rank, y = change, fill = color, group = country)) +
geom_col(width = 0.85, show.legend = FALSE) +
# country names left of the panel, value at the end of each bar
geom_text(aes(y = lo - pad, label = country, color = color),
hjust = 1, size = 3.8, fontface = "bold", show.legend = FALSE) +
geom_text(aes(y = pmax(change, 0) + pad, label = pct(change)),
hjust = 0, color = "gray80", size = 3.2) +
geom_hline(yintercept = 0, color = "#546E7A", linewidth = 0.4) +
scale_fill_identity() +
scale_color_identity() +
scale_x_reverse(breaks = NULL) +
scale_y_continuous(labels = function(v) paste0(v, "%"),
expand = expansion(mult = c(0.02, 0.1))) +
geom_text(
data = df_rent %>% dplyr::distinct(year),
aes(x = n_countries, y = hi, label = year),
inherit.aes = FALSE,
hjust = 1, vjust = 0,
size = 20, color = "white", alpha = 0.15,
fontface = "bold"
) +
coord_flip(clip = "off") +
labs(title = title_rent,
x = NULL,
y = "% vs 2015",
caption = "Source: OECD Analytical house prices indicators | Greece highlighted in red") +
theme_minimal(base_size = 10) +
theme(
plot.background = element_rect(fill = "#0F1923", color = NA),
panel.background = element_rect(fill = "#0F1923", color = NA),
panel.grid.major.x = element_line(color = "#1E2D3D", linewidth = 0.4),
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank(),
axis.text.x = element_text(color = "#90A4AE", size = 10),
axis.text.y = element_blank(),
axis.title.x = element_text(color = "#90A4AE", size = 10),
plot.title = element_text(color = "white", face = "bold", size = 12,
margin = margin(12, 0, 8, 0)),
plot.caption = element_text(color = "#546E7A", size = 8, hjust = 1,
margin = margin(8, 0, 8, 0)),
plot.margin = margin(10, 40, 10, 130)) +
transition_states(year, transition_length = 0, state_length = 5) +
ease_aes("cubic-in-out")
animate(p,
nframes = 100,
fps = 10,
duration = 20,
width = 900,
height = 900,
units = "px",
res = 100, # set explicitly: when knitting, gganimate otherwise uses the chunk dpi
renderer = gifski_renderer("greek_housing_race.gif"))Note: rendering the GIF takes a couple of minutes and requires an internet connection to reach the OECD API. The GIF is written to the working directory (
getwd()).
Rendered with R 4.6.1 · plotly · ggplot2 · gganimate · OECD SDMX API