+ +

University · 2024

Interactive Data Visualisation, A Shiny Exploration Surface

An R Shiny dashboard over 76 years of Australian Consumer Price Index data, with a quarterly date range, eight capital cities and eleven expenditure groups the reader can select between.

Tools
R · Shiny · flexdashboard · plotly · reactive programming
Date
August 2024
Live
squigglykip.shinyapps.io/assignment3/

A static chart makes a claim. An interactive one hands the reader the controls and lets them test it. That shift matters more than it sounds, because the moment you provide a filter you have changed what the picture is for: it stops being “what I want you to see” and becomes “what you can find”.

The Consumer Price Index is close to an ideal subject for that argument. Everybody has a view on the cost of living and almost nobody agrees on how to read the number, because the figure that matters depends entirely on which basket and which window you care about. Any single static CPI chart is somebody’s editorial choice about which of those to show.

This dashboard covers both. It reads two ABS series from the June 2024 CPI release: all-groups CPI by capital city, quarterly from September 1948, and CPI by expenditure group for Australia, quarterly from September 1972. Three pages, built with flexdashboard over a Shiny runtime and plotly for every chart, deployed to shinyapps.io so it runs without anyone installing R.

The headline the first page produces is the scale of the whole thing. Australian all-groups CPI sat at 3.7 in September 1948 and 138.8 in June 2024, an increase of 135.1 index points. On the ABS reference base of 2011-12 = 100, that means a dollar of 1948 spending is worth about $37.51 of 2024 spending. The app computes that sentence live from whatever window the slider is set to, which is the point: change the window and the sentence changes with it.

The third page is where the dashboard earns its keep, because it separates the aggregate into eleven expenditure groups and the groups have gone in different directions. Since September 1972, alcohol and tobacco has risen 35-fold, driven by excise rather than by market prices. Housing has risen about 15-fold and food about 13-fold. Communication has risen 2.6-fold and now sits at 78.2, meaningfully below its own 2011-12 level. Clothing and footwear sits at 99.8, which is to say roughly where it was in 2011-12 after fifty years of rising and then falling back. A reader who selects only those two categories sees a completely different story about the cost of living from one who selects only the first three, and both stories are true. That is the argument for the interactive form, stated in data rather than in principle.

In brief

  • Two ABS series, table 640101 (all-groups CPI by capital city) and table 640102 (CPI by expenditure group), from the June 2024 release
  • 304 quarterly observations per city series, September 1948 to June 2024; 208 per expenditure group, September 1972 to June 2024
  • Nine geographies: eight capital cities plus the weighted average for Australia
  • Eleven expenditure groups, each individually selectable
  • Australian all-groups CPI: 3.7 in September 1948 to 138.8 in June 2024, so $1 of 1948 spending equates to about $37.51
  • Widest city spread at June 2024: Brisbane at 140.6 against Darwin at 133.6, a gap of 7.0 index points
  • Largest single-quarter national rise in the series: 7.5% in December 1951; largest fall: 1.9% in June 2020
  • Divergence across groups since 1972: alcohol and tobacco up 35-fold, communication up 2.6-fold
  • Three pages, four input controls, six plotly outputs, all reactive to the date range

The app sleeps when idle on the free tier and takes a moment to wake. If the frame above stays blank, open it directly at squigglykip.shinyapps.io/assignment3.


The report as submitted

1. The data

Both source files are ABS time series workbooks in their standard layout: a Data1 sheet with nine rows of series metadata above the observations, dates in the first column as Excel serial numbers, and one column per series with the series name as the header.

Table 640101, all-groups CPI by capital city. 27 series covering index numbers, percentage change from the corresponding quarter of the previous year, and percentage change from the previous period, each for Sydney, Melbourne, Brisbane, Adelaide, Perth, Hobart, Darwin, Canberra and Australia. Quarterly, September 1948 to June 2024, 304 observations. The Darwin and Canberra series start later than the others, so those columns carry nulls at the front.

Table 640102, CPI by expenditure group. Index numbers for food and non-alcoholic beverages, alcohol and tobacco, clothing and footwear, housing, furnishings and household equipment, health, transport, communication, recreation and culture, education, and insurance and financial services, all for Australia. Quarterly, mostly from September 1972, with health and recreation from September 1989, education from March 1982 and insurance and financial services from June 2005.

Loading is the same shape for both: strip the metadata rows, name the date column, convert the Excel serial dates, coerce everything else to numeric.

data_raw <- read_excel("640101.xlsx", sheet = "Data1")
cpi_city_data <- data_raw[-c(1:9), ]
names(cpi_city_data)[1] <- "Date"
cpi_city_data <- cpi_city_data %>%
  mutate(Date = as.Date(as.numeric(Date), origin = "1899-12-30")) %>%
  mutate_at(vars(-Date), as.numeric)

The expenditure group table is additionally filtered from 1 September 1972, which drops the leading rows where the file carries dates from 1948 but no values for any of the groups.

The series names arrive from the ABS with a distinctive format, Index Numbers ; All groups CPI ; Sydney ;, complete with double spaces and trailing semicolons. Those are used as-is for column selection and stripped with str_extract() at the point of plotting rather than renamed on import, which keeps a direct line back to the source file.

2. Preparation done once, outside the reactive layer

Three preparation steps run at startup rather than on every input change.

Long-format city data. The index columns and the percentage change columns are pivoted separately and then merged on date and city, giving one row per city per quarter with both measures side by side.

index_data <- cpi_city_data %>%
  select(Date, starts_with("Index Numbers ;  All groups CPI ;")) %>%
  pivot_longer(-Date, names_to = "City", values_to = "CPI_Index") %>%
  mutate(City = str_extract(City, "Sydney|Melbourne|Brisbane|Adelaide|Perth|Hobart|Darwin|Canberra"))

Decade averages. The national index is grouped into decades, averaged, and then expanded back out to one row per year so it can be drawn as a step line over the same x-axis as the quarterly series.

decade_avg_df <- cpi_city_data %>%
  mutate(Decade = as.numeric(format(Date, "%Y")) %/% 10 * 10) %>%
  group_by(Decade) %>%
  summarise(Decade_Avg = mean(`Index Numbers ;  All groups CPI ;  Australia ;`, na.rm = TRUE)) %>%
  mutate(End_Year = Decade + 9) %>%
  rowwise() %>%
  mutate(Date = list(seq(as.Date(paste0(Decade, "-01-01")),
                         as.Date(paste0(End_Year, "-12-31")), by = "years"))) %>%
  ungroup() %>%
  unnest(cols = c(Date)) %>%
  select(Date, Decade_Avg)

Global axis limits. Minimum and maximum values are computed across all eight cities for both the index and the percentage change, and stored as plain variables.

These limits are what make the small-multiples page readable. Eight plotly panels each auto-scaling to their own data would give eight different y-axes and no basis for comparing across them. Computing the bounds once and applying them to every panel means a difference in line height is a real difference.

3. Page one, overall CPI

The first page carries a summary card, a date range slider and the national timeline.

The slider is a sliderTextInput whose choices are the actual quarter dates in the data rather than a continuous range, so every position it can stop at corresponds to a real observation.

sliderTextInput(
  inputId  = "date_range",
  label    = "Select Date Range:",
  choices  = as.character(seq(min(cpi_city_data$Date), max(cpi_city_data$Date), by = "quarter")),
  selected = as.character(range(cpi_city_data$Date)),
  grid     = FALSE
)

That slider drives a reactive that filters the city data, and a second reactive that computes the summary numbers.

filtered_city_data <- reactive({
  req(input$date_range)
  cpi_city_data %>%
    filter(Date >= as.Date(input$date_range[1]) & Date <= as.Date(input$date_range[2]))
})

cpi_increase <- reactive({
  data <- filtered_city_data()
  min_cpi <- min(data$`Index Numbers ;  All groups CPI ;  Australia ;`, na.rm = TRUE)
  max_cpi <- max(data$`Index Numbers ;  All groups CPI ;  Australia ;`, na.rm = TRUE)
  list(min_cpi = min_cpi, max_cpi = max_cpi, increase = max_cpi - min_cpi,
       min_date = min(data$Date), max_date = max(data$Date))
})

The card renders a sentence from that list: how many index points CPI rose over the selected window, between which two months, and what a dollar at the start of the window would be worth at the end of it. Over the full range that reads as an increase of 135.1 points and a dollar equivalence of $37.51. Narrow the window to the last decade and the same card recomputes to a much smaller number, which is exactly the interrogation the interactive form is there to allow.

The main chart puts three traces on two axes: the national index as a filled area, the quarter-on-quarter percentage change as a spline on a secondary right-hand axis, and the decade average as a step line over the top of the index.

The two-axis choice is the one worth defending. The index and the percentage change are on completely incompatible scales, one running to 138 and the other between -2 and 8, and the whole reason to show them together is that the flat-looking stretches of the index are the volatile stretches of the percentage change. The 1951 spike, where the national index rose 7.5% in a single quarter, is invisible on the index line and unmissable on the percentage change line. The decade average sits between them as a coarse reference that stops the eye from reading quarterly noise as trend.

4. Page two, cities

The second page compares the eight capitals. An insights card computes the highest and lowest city index, the gap between them, and the largest and smallest percentage changes with their dates. At June 2024 that is Brisbane at 140.6 against Darwin at 133.6, seven index points apart.

A fixed colour vector is defined once and reused by all three charts on the page, so a city keeps the same colour wherever it appears.

city_colors <- c("Sydney" = 'blue', "Melbourne" = 'green', "Brisbane" = 'red',
                 "Adelaide" = 'purple', "Perth" = 'orange', "Hobart" = 'pink',
                 "Darwin" = 'cyan', "Canberra" = 'brown')

Three tabs sit under it.

City CPI timeline. Eight panels in a three-row subplot, one per city, sharing both axes. Each panel is annotated twice: an arrow marking the maximum index value with its date, and a large translucent city name behind the plot area so the panel identifies itself without a title bar eating vertical space.

index_plots <- lapply(unique(preprocessed_city_data$City), function(city) {
  city_plot_data <- filter(preprocessed_city_data, City == city)
  max_cpi <- filter(max_cpi_data, City == city)
  plot_ly(city_plot_data, x = ~Date, y = ~CPI_Index, type = 'scatter', mode = 'lines',
          fill = 'tozeroy', fillcolor = adjustcolor(city_colors[city], alpha.f = 0.5),
          line = list(color = city_colors[city])) %>%
    layout(xaxis = list(range = c(global_x_min, global_x_max)),
           yaxis = list(range = c(global_y1_min, global_y1_max)), ...)
})
index_subplot <- subplot(index_plots, nrows = 3, shareX = TRUE, shareY = TRUE)

Generating the panels with lapply() rather than writing eight blocks means the colour rule, the axis rule and the annotation rule are each stated once. Adding a ninth geography would need no new plotting code.

City percentage change timeline. The same construction over the quarter-on-quarter change, again on shared axes. This is the tab where the cities visibly diverge: the early 1950s spikes are national but not equal, running from 6.3% in Perth to 9.8% in Brisbane in a single quarter, and the June 2020 fall is sharpest in Darwin at -2.5%.

Max CPI by city. A bar chart of each city’s maximum index, sorted ascending, using the same colour map. The least sophisticated chart on the page and the one that answers the most common question, which is simply which city is most expensive relative to its own history.

5. Page three, goods and services

The third page is the one built around a question the reader supplies rather than one the author picked.

Three inputs: a date range slider, a checkbox group over all eleven expenditure categories with everything selected by default, and a numeric input for a dollar amount defaulting to $100.

The output is a rendered narrative rather than a chart. For each selected category, the app finds the minimum and maximum index within the chosen window, calculates the percentage increase, and applies that ratio to the dollar amount.

min_cpi <- min(filtered_data[[category_col]], na.rm = TRUE)
max_cpi <- max(filtered_data[[category_col]], na.rm = TRUE)
percent_increase <- ((max_cpi - min_cpi) / min_cpi) * 100
adjusted_cost    <- dollar_amount * (max_cpi / min_cpi)

It then writes a paragraph per category: what the index was at the start, what it was at the end, the percentage rise, and what the entered dollar amount would have to become to buy the same thing. Underneath sits a multi-line plotly timeline of the selected categories, using a fixed eleven-colour map so a category keeps its colour as the selection changes.

Selecting one category at a time is what makes the divergence legible. Across the full window from September 1972 to June 2024:

Expenditure groupSep 1972Jun 2024Multiple
Alcohol and tobacco5.7200.735.2
Housing10.3150.314.6
Food and non-alcoholic beverages10.1134.013.3
Transport11.3132.411.7
Furnishings and household equipment17.5122.37.0
Clothing and footwear19.199.85.2
Communication29.978.22.6
All groups CPI10.8138.812.9

Two entries in that table are below 100 at June 2024, meaning both sit below the 2011-12 reference base. Clothing and footwear rose for four decades and has given most of the last decade’s gains back. Communication has fallen steadily since the mid-1990s. Neither is visible in the all-groups line, which is the point of a page that lets the reader take the aggregate apart.

The narrative panel handles its own failure case. The whole block is wrapped in tryCatch(), and if the inputs are not yet available it renders a prompt asking the reader to adjust the filters rather than showing a Shiny error. Categories missing from the data, or present but non-numeric, each produce a specific message instead of stopping the render.

6. Reactive structure

The pattern throughout is the same. Anything that does not depend on user input is computed once at startup as an ordinary object. Anything that does is wrapped in reactive() and called as a function. Outputs declare what they read and Shiny works out what needs redrawing.

filtered_decade_avg <- reactive({
  req(input$date_range)
  decade_avg_df %>%
    filter(Date >= as.Date(input$date_range[1]) & Date <= as.Date(input$date_range[2]))
})

req() at the top of each reactive is what stops the app from rendering a broken chart during startup, before the inputs have registered.

The dividend of this structure is that the plotting code is the same code that would draw a static chart. There is no Shiny-specific plotting layer. The reactive wrapper decides which rows to hand to plotly, and plotly does the same thing it would do in a report.

7. What is wrong with it

Two defects are worth recording.

Duplicate input identifier. Page one and page three each define an input called date_range, one a sliderTextInput over quarter strings and the other a sliderInput over dates. Shiny keeps a single namespace for input identifiers across the whole app, so the two controls collide. The reactives on page one read whichever value most recently bound to that name, which makes the behaviour depend on rendering order rather than on the design. The correct fix is two distinct identifiers, or one shared control lifted out of both pages.

Styling by string concatenation. The YAML declares css: styles.css, but that file is empty. Every visual choice in the summary and narrative cards is an inline style attribute inside a paste0() call building HTML. It works, and it makes the cards tedious to change and impossible to restyle consistently. Those declarations belong in the stylesheet that is already wired up.

Two limitations are properties of the data rather than the build. The expenditure group table covers Australia only, so the cost-of-living breakdown cannot be examined city by city. And the dollar equivalence calculation uses the minimum and maximum index within the selected window rather than its first and last values, which is the same thing for a monotonically rising series and is not the same thing for communication or clothing, where the maximum is not the most recent observation.

8. What the interactive form earns

The dashboard lets a reader select their own slice and check a claim against it. That is the move the static version cannot make. It also lets them see the shape of the data before any interpretation arrives, which matters when the audience includes people who will second-guess the author’s framing regardless.

Hosting decided the scope. shinyapps.io on the free tier costs nothing and sleeps when idle, which suits an artefact meant to be looked at occasionally. Self-hosting would mean running R as a service, which is a larger commitment than this piece of work justifies.

References

Australian Bureau of Statistics 2024, Consumer price index, Australia, June 2024, Australian Bureau of Statistics, viewed 10 August 2024, https://www.abs.gov.au/statistics/economy/price-indexes-and-inflation/consumer-price-index-australia/latest-release.

Contents
KJ·OS v4 · content/projects University
↩ All work