Every year, Victoria’s crime figures are read as a report card on how people behave. But some offences only exist because a court has put conditions on someone, and Parliament decides whether breaking those conditions is a crime. Between March 2024 and March 2025, Parliament changed its mind about one of them, and then changed it back. This report follows that decision through ten years of official data from Victoria’s Crime Statistics Agency.

1 in 6recorded offences in 2025–26 were offences against the justice system itself, up from 13% of offences in 2016–17
11,723 → 16,986breach-of-bail offences recorded in 2024–25 and in 2025–26, after offending on bail was made a crime again
24% → 31%share of women’s bail applications refused in the Magistrates’ Court, 2024–25 to 2025–26

Who this is for: voters, journalists and anyone weighing “tough on bail” promises ahead of the November 2026 Victorian state election. How to use it: every chart is interactive. Hover for exact figures, click legend items to hide or show a series, and use the grey buttons above a chart to switch views.

1 · Orient Offences against the justice system have grown 3.5 times as fast as other recorded crime

Most recorded crime is theft, property damage and assault. But one category, justice procedures offences, has been climbing steadily. This category covers breaches of family violence orders, intervention orders and bail conditions, and resisting police. Between 2016–17 and 2025–26 it grew by 45%, against 13% for all other offences combined.

Try it: switch between growth since 2016–17 and number of offences. The growth view puts every category on the same footing. The counts view shows how big each category is. Click a category in the legend to hide it.

Justice procedures offences grew from 69,279 to 100,463, the fastest growth of any offence division.

Chart 1. Each line shows how one offence division changed relative to 2016–17. Justice procedures offences (solid line) rose 45%; all other offences combined rose 13%. Their share of all recorded offences moved from 13% to 16%. Offences at justice institutions, in unincorporated Victoria and at unknown locations are excluded because CSA does not publish them by LGA.
These offences mostly don’t measure a new act of theft or violence. They record a breach of an order the justice system has already put in place. Breaches of family violence orders make up the largest part, and those can involve real harm to victims. But the count rises and falls with how many orders exist, how actively police enforce them, and what the law says counts as a breach.

2 · Reveal One offence was switched off in March 2024 and back on in March 2025, and three record systems show the same V

On 25 March 2024, the Bail Amendment Act 2023 commenced. It repealed the offences of committing an indictable offence while on bail and breaching a bail conduct condition (Parsons & George, 2024; Victoria Legal Aid, 2024). From 26 March 2025, further amendments made committing an indictable offence on bail a crime again (Robinson Gill, n.d.).

Recorded breach-of-bail offences fell from 16,664 in 2022–23 to 11,723 in 2024–25, a 30% drop. They then rebounded to 16,986 in 2025–26, the highest in the decade. Police bail decisions and court applications involving a breach charge trace the same shape.

Try it: the shaded band marks the twelve months when offending on bail was not a separate crime. Each annual figure covers July–June, so the 2023–24 and 2024–25 points were only partly affected. Switch to index to compare the three systems on a common base (2022–23 = 100). Hover over any point for the exact count.

All three breach-of-bail measures fall across 2023-24 and 2024-25 and rise sharply in 2025-26.

Chart 2. All three series dip during or after the repeal window and rise once the offence returns. Recorded offences: 16,664 → 11,723 → 16,986. Police decisions with a breach charge: 7,352 → 5,886 → 10,770. Court applications: 18,709 → 8,615 → 16,145 (2022–23 → 2024–25 → 2025–26).
Victorians did not suddenly become far more law-abiding in 2024 and then far less so in 2025. The rule changed, and the count followed. When a crime figure moves this sharply, check the statute book before drawing conclusions.

The data can’t separate the legal change from other influences, such as police enforcement priorities, the wider tightening of the bail test in 2025, and court workloads. This is a strong descriptive association with a clear legal mechanism, not a controlled causal estimate.

3 · Explain The rebound was not confined to a few hotspots: 72 of 79 council areas rose

Each bubble is a local government area (LGA). The horizontal position is its breach-of-bail rate per 100,000 residents before a law change, and the vertical position is the rate after. Bubbles above the diagonal rose. During the repeal period, 62 of 79 LGAs fell. In the reinstatement year, 72 of 79 rose. All four police regions increased, led by Eastern, where the rate per head rose 58%. Among larger councils (50,000+ residents), Maroondah rose most, from 361 to 684 per 100,000.

Try it: choose a period with the buttons. Hover over a bubble to see the LGA, its counts and its rates. Click a police region in the legend to hide it, or double-click to isolate it. Bubble size shows population, and small towns can swing widely on a handful of offences.

In the repeal period 62 of 79 LGAs fall below the diagonal; in the reinstatement year 72 of 79 rise above it.

Chart 3. Default view: the reinstatement year. 72 of 79 LGAs recorded a higher breach-of-bail rate in 2025–26 than in 2024–25. Change in rate per head by police region: Eastern +58%; North West Metro +48%; Western +41%; Southern Metro +34%.

Rates in small LGAs rest on few offences, and offences are counted where they are recorded, not where the person lives. Differences between areas may reflect policing practice and access to bail support services as much as offending.

4 · Challenge Tougher bail didn’t only catch breach cases: women’s refusal rate jumped most

The 2025 reforms did more than restore one offence. They also tightened the bail test. In the Magistrates’ Court, the share of women’s bail applications refused rose from 23.6% in 2024–25 to 31.3% in 2025–26. Men’s rate moved from 33.7% to 34.8%. The rise was steepest for women aged 18–24 (16.9% → 28.5%). Applications that involved no breach-of-bail offence were also refused more often: 29.2% in 2023–24 and 32.6% in 2025–26.

Try it: each panel is an age group, and every panel shares the same scale. Hover to see how many applications were refused out of the total. Click Women or Men in the legend to compare one group at a time across all five panels.

Women's bail refusal rate rose from 23.6% to 31.3%, nearly reaching men's rate of 34.8%.

Chart 4. Refusal rate = applications refused ÷ all applications decided (granted, refused or revoked). Women's refusal rate rose 7.7 percentage points in one year; men's rose 1.1. Total applications rose 24% over the same year.
The 2024 reforms followed the coronial inquest into the death in custody of Veronica Nelson, a Gunditjmara, Dja Dja Wurrung, Wiradjuri and Yorta Yorta woman held on remand over alleged minor shoplifting. The coroner found that Victoria’s bail laws had a discriminatory impact on Aboriginal and Torres Strait Islander people, and that First Nations women were the most severely affected (O’Kearney, 2026). In 2025–26, Aboriginal and/or Torres Strait Islander people made up 20% of Magistrates’ Court bail applications. The rise in women’s refusals shown above is exactly the pattern that finding warned about.

Court bail tables start in 2020–21, and Indigenous status is only published from 2024–25, so a longer trend can’t be shown. Refusal rates don’t account for differences in the seriousness of the charges.

5 · Conclude In much of regional Victoria, a quarter or more of recorded “crime” is a breach of the justice system’s own orders

When a council area tops a crime league table, it is natural to assume more people there are offending. Yet justice procedures offences are 16% of all offences statewide, and 18 LGAs are at 25% or more. In Benalla the share reaches 35%, compared with 10% in the City of Melbourne. Comparing LGAs on headline totals partly compares how many court orders each area manages and how closely they are policed.

Try it: re-sort the LGAs with the buttons: by share in 2025–26, by change since 2016–17, or alphabetically to find your own council. The hollow dot is 2016–17 and the filled dot is 2025–26. Hover for counts, and click a region in the legend to hide it.

Justice procedures offences are 16% of offences statewide; 18 LGAs are at 25% or above, led by Benalla at 35%.

Chart 5. 79 LGAs. Share of recorded offences that are justice procedures offences, by police region in 2025–26: Western 22%; Eastern 19%; Southern Metro 16%; North West Metro 12%.

What the evidence supports, and what it doesn’t

What the data shows.

  • A measurable slice of Victoria’s recorded crime, 16% in 2025–26, consists of breaches of orders and other offences against the justice system. This slice has grown faster than any other offence division.
  • Breach-of-bail numbers in three separate record systems fell during and after the 2024 repeal and rebounded after the 2025 reinstatement. The timing matches the legal change.
  • The 2025 reforms coincided with more bail refusals across every age and sex group, rising most sharply for women.
  • In regional Victoria, justice procedures offences make up a far larger share of recorded crime than in central Melbourne.

What it can’t show.

  • These figures count offences, decisions and applications, not unique people.
  • They can’t isolate the effect of one legal change from enforcement practice, court workloads and other parts of the 2025 reforms.
  • They say nothing about whether bail changes made the community safer.

The takeaway for election season: before using a crime figure as evidence that “crime is out of control”, or under control, ask how much of it the law itself created.

About the data and method

Data sources and quality

Dataset (CSA, year ending June 2026) Unit of observation Period Rows Used in
LGA Recorded Offences (Tables 01, 02) Offences recorded, by LGA × offence subgroup × financial year 2016–17 to 2025–26 52,809 Charts 1, 2, 3, 5
Victoria Police bail data tables (Table 04) Initial police bail decisions by breach-offence involvement × year 2016–17 to 2025–26 50 Chart 2
Magistrates’ Court bail data tables (Tables 01, 02, 04) Bail applications by outcome × sex × age group (and × Indigenous status, × breach involvement) × year 2020–21 to 2025–26 270 Charts 2, 4

All three tables come from one independent source, the Crime Statistics Agency (CSA) Victoria, published September 2026 under a CC BY 3.0 AU licence. The CSA compiles them from Victoria Police’s LEAP database and Court Services Victoria records.

Quality checks performed in the code:

  • Required columns and all ten financial years are present, with no missing or negative counts. Rendering stops if the CSA changes the published structure.
  • Summing Table 02 by LGA reproduces Table 01’s totals to within 250 offences out of 5,376,912 (0.005%). The gap reflects CSA’s own table construction.
  • LGA population is derived as count ÷ published rate × 100,000, so every rate in this report is consistent with CSA’s population base.

Preprocessing steps

  1. Raw files. The data are read unchanged from the official .xlsx files, which are downloaded automatically if missing.
  2. Year labels. The CSA’s “Year 2026, year ending June” is relabelled as financial year 2025–26.
  3. Statewide totals. These are summed from LGA-level Table 02. Justice institutions, unincorporated Victoria and unknown locations are excluded because CSA does not publish them at LGA level.
  4. Breach of bail is offence subgroup E23 Breach bail conditions. Justice procedures offences are offence division E.
  5. Law-change dates are placed on the financial-year axis in proportion to how far through the year they fell.
  6. Refusal rate is bail refused ÷ (granted + refused + revoked), using the CSA’s own sex and age totals.

Interaction design

Chart Interaction Question it lets the reader ask
1 Growth ↔︎ counts toggle; legend filter Is justice-procedure growth large in absolute terms as well as relative terms?
2 Counts ↔︎ index toggle; shaded law window Do independent systems move together around the law change?
3 Period switch (repeal vs reinstatement); region filter; bubble hover Which LGAs and regions drove each movement?
4 Linked legend across five panels; hover with refused/total Which age and sex groups were refused more often?
5 Re-sort by share, change or name; region filter Where does my council sit, and how has that changed?

Full R code

All code used to load, validate, preprocess, analyse and visualise the data. It is the same code that runs above, shown here in order.

# ---------------------------------------------------------------------------
# 0. SETUP
# Packages (install once if needed):
# install.packages(c("readxl", "dplyr", "tidyr", "stringr", "purrr",
#                    "scales", "plotly", "htmltools", "knitr", "rmarkdown"))
# ---------------------------------------------------------------------------
knitr::opts_chunk$set(echo = FALSE, message = FALSE, warning = FALSE,
                      fig.align = "center")
library(readxl)     # read the official CSA .xlsx tables
library(dplyr)      # data wrangling
library(tidyr)      # reshaping
library(stringr)    # string helpers
library(purrr)      # iteration
library(scales)     # number formatting
library(plotly)     # interactive charts (all visualisations built in R)
library(htmltools)  # accessible figure wrappers
# ---------------------------------------------------------------------------
# 1. DATA LOADING
# All data come from ONE publisher: the Crime Statistics Agency (CSA) Victoria,
# release "year ending June 2026" (published September 2026).
# Files are read from the local data/ folder. If a file is missing, it is
# downloaded directly from the CSA website, so the analysis is reproducible.
# ---------------------------------------------------------------------------
data_dir <- "data"
dir.create(data_dir, showWarnings = FALSE)

csa_base <- "https://files.crimestatistics.vic.gov.au/2026-09/"
sources <- tibble::tribble(
  ~key,      ~local_file,                                                                    ~remote_file,
  "offences", "Data_Tables_LGA_Recorded_Offences_Year_Ending_June_2026.xlsx",                 "Data_Tables_LGA_Recorded_Offences_Year_Ending_June_2026.xlsx",
  "police",   "Victoria_Police_Data_Tables_Bail_Visualisation_Year_Ending_June_2026_0.xlsx",  "Victoria_Police_Data_Tables_Bail_Visualisation_Year_Ending_June_2026_0.xlsx",
  "court",    "Magistrates_Court_Data_Tables_Bail_Visualisation_Year_Ending_June_2026_1.xlsx", "Magistrates%27_Court_Data_Tables_Bail_Visualisation_Year_Ending_June_2026_1.xlsx"
) %>%
  mutate(path = file.path(data_dir, local_file),
         url  = paste0(csa_base, remote_file))

for (i in seq_len(nrow(sources))) {
  if (!file.exists(sources$path[i])) {
    # accept the browser's original file name (some keep the apostrophe in "Magistrates'")
    on_disk <- list.files(data_dir)
    match <- on_disk[str_remove_all(on_disk, "['\u2019]") == sources$local_file[i]]
    if (length(match)) {
      sources$path[i] <- file.path(data_dir, match[1])
    } else {
      download.file(sources$url[i], sources$path[i], mode = "wb")
    }
  }
}
path_of <- function(k) sources$path[sources$key == k]

# Recorded offences by LGA (Table 01) and by offence subgroup x LGA (Table 02)
lga_tot_raw <- read_excel(path_of("offences"), sheet = "Table 01")
lga_off_raw <- read_excel(path_of("offences"), sheet = "Table 02")
# Victoria Police initial bail decisions, by breach-of-bail involvement (Table 04)
vp_breach_raw <- read_excel(path_of("police"), sheet = "Table 04")
# Magistrates' Court bail applications: outcome x sex x age (Table 01),
# outcome x breach-of-bail involvement (Table 04), outcome x Indigenous status (Table 02)
mc_age_raw    <- read_excel(path_of("court"), sheet = "Table 01")
mc_breach_raw <- read_excel(path_of("court"), sheet = "Table 04")
mc_ind_raw    <- read_excel(path_of("court"), sheet = "Table 02")
# ---------------------------------------------------------------------------
# 2. VALIDATION — stop rendering if the published structure has changed
# ---------------------------------------------------------------------------
expect_cols <- function(df, cols, name) {
  missing <- setdiff(cols, names(df))
  if (length(missing)) stop(name, " is missing columns: ", paste(missing, collapse = ", "))
}
expect_cols(lga_tot_raw, c("Year", "Police Region", "Local Government Area",
                           "Offence Count", "Rate per 100,000 population"), "Offences Table 01")
expect_cols(lga_off_raw, c("Year", "Local Government Area", "Offence Division",
                           "Offence Subgroup", "Offence Count"), "Offences Table 02")
expect_cols(vp_breach_raw, c("Year", "Bail Offence Type", "Breach Offence", "Record Count"), "Police Table 04")
expect_cols(mc_age_raw, c("Year", "Outcome", "Sex", "Age Group", "Record Count"), "Court Table 01")
expect_cols(mc_breach_raw, c("Year", "Bail Outcome", "Breach of Bail Offence", "Record Count"), "Court Table 04")

stopifnot(
  all(2017:2026 %in% lga_off_raw$Year),            # ten financial years
  !anyNA(lga_off_raw$`Offence Count`),
  all(lga_off_raw$`Offence Count` >= 0),
  any(str_starts(lga_off_raw$`Offence Subgroup`, "E23")),  # breach bail subgroup exists
  all(2021:2026 %in% mc_age_raw$Year)
)
# ---------------------------------------------------------------------------
# 3. PREPROCESSING
# ---------------------------------------------------------------------------
# Financial-year label: CSA "Year 2026, Year ending June" = July 2025 to June 2026
fy <- function(y) paste0(y - 1, "–", str_sub(y, 3, 4))
latest <- max(lga_off_raw$Year)
first  <- min(lga_off_raw$Year)

lga_off <- lga_off_raw %>%
  transmute(year = Year,
            lga = `Local Government Area`,
            division = `Offence Division`,
            div_code = str_sub(`Offence Division`, 1, 1),
            subgroup = `Offence Subgroup`,
            sub_code = str_extract(`Offence Subgroup`, "^[A-F][0-9]+"),
            n = `Offence Count`)

# LGA population is not published as a column, but CSA publishes the count and
# the rate per 100,000 for each LGA-year. Population = count / rate * 100,000.
# "Total", justice institutions and unincorporated areas have no rate and are dropped.
lga_pop <- lga_tot_raw %>%
  transmute(year = Year,
            lga = `Local Government Area`,
            region = str_remove(`Police Region`, "^[0-9]+ "),
            total_t01 = `Offence Count`,
            rate_t01 = `Rate per 100,000 population`) %>%
  filter(!is.na(rate_t01), rate_t01 > 0,
         !lga %in% c("Total", "Justice Institutions and Immigration Facilities", "Unincorporated Vic")) %>%
  mutate(pop = total_t01 / rate_t01 * 1e5)

# One row per LGA-year with the measures used in charts 3 and 5
lga_year <- lga_off %>%
  group_by(year, lga) %>%
  summarise(all_off = sum(n),
            justice = sum(n[div_code == "E"]),
            breach_bail = sum(n[sub_code == "E23"]),
            .groups = "drop") %>%
  inner_join(lga_pop, by = c("year", "lga")) %>%
  mutate(breach_rate = breach_bail / pop * 1e5,
         justice_share = justice / all_off)

# Quality check: Table 02 (summed) vs Table 01 totals per LGA-year
recon <- lga_year %>% summarise(abs_diff = sum(abs(all_off - total_t01)),
                                total = sum(total_t01))

# Statewide offence divisions (Chart 1). Division F "Other offences" is
# shown separately because a COVID-19 public-health spike distorts the scale.
div_year <- lga_off %>%
  group_by(year, div_code, division) %>%
  summarise(n = sum(n), .groups = "drop") %>%
  group_by(division) %>%
  mutate(index = n / n[year == first] * 100) %>%
  ungroup()

split_year <- lga_off %>%
  mutate(grp = if_else(div_code == "E", "justice", "other")) %>%
  group_by(year, grp) %>% summarise(n = sum(n), .groups = "drop") %>%
  pivot_wider(names_from = grp, values_from = n) %>%
  mutate(share = justice / (justice + other))

covid_peak <- lga_off %>% filter(sub_code == "F92") %>%
  group_by(year) %>% summarise(n = sum(n)) %>% slice_max(n, n = 1)

# Three independent measuring systems for breach of bail (Chart 2)
bail_series <- bind_rows(
  lga_off %>% filter(sub_code == "E23") %>% group_by(year) %>%
    summarise(n = sum(n)) %>%
    mutate(series = "Breach-of-bail offences recorded by police"),
  vp_breach_raw %>% filter(`Breach Offence` == "Breach bail offence") %>%
    group_by(year = Year) %>% summarise(n = sum(`Record Count`)) %>%
    mutate(series = "Police bail decisions involving a breach-of-bail charge"),
  mc_breach_raw %>% filter(`Breach of Bail Offence` == "Breach bail offence") %>%
    group_by(year = Year) %>% summarise(n = sum(`Record Count`)) %>%
    mutate(series = "Magistrates' Court bail applications involving a breach-of-bail offence")
) %>%
  group_by(series) %>%
  mutate(index = n / n[year == 2023] * 100) %>% ungroup()

# Court outcomes by sex and age (Chart 4)
court_sexage <- mc_age_raw %>%
  group_by(year = Year, sex = Sex, age = `Age Group`) %>%
  summarise(refused = sum(`Record Count`[Outcome == "Bail refused"]),
            applications = sum(`Record Count`),
            .groups = "drop") %>%
  mutate(refusal = refused / applications,
         sex = recode(sex, Females = "Women", Males = "Men", Total = "All applicants"),
         age = recode(age, "18-24* years" = "18–24", "25-34 years" = "25–34",
                      "35-44 years" = "35–44", "45+ years" = "45+", "Total" = "All ages"))

court_breach <- mc_breach_raw %>%
  filter(`Breach of Bail Offence` != "Total") %>%
  group_by(year = Year, breach = `Breach of Bail Offence`) %>%
  summarise(refusal = sum(`Record Count`[`Bail Outcome` == "Bail refused"]) / sum(`Record Count`),
            .groups = "drop")

court_ind <- mc_ind_raw %>%
  filter(Sex == "Total", `Age Group` == "Total") %>%
  group_by(year = Year, status = `Indigenous Status`) %>%
  summarise(applications = sum(`Record Count`), .groups = "drop")
# ---------------------------------------------------------------------------
# 4. KEY NUMBERS used in the text — all calculated, none typed in by hand
# ---------------------------------------------------------------------------
pct  <- function(x, acc = 1) percent(x, accuracy = acc)
num  <- function(x) comma(round(x))
chg  <- function(a, b) (b - a) / a

sy <- split_year
justice_growth <- chg(sy$justice[sy$year == first], sy$justice[sy$year == latest])
other_growth   <- chg(sy$other[sy$year == first],   sy$other[sy$year == latest])
share_first    <- sy$share[sy$year == first]
share_latest   <- sy$share[sy$year == latest]
one_in         <- round(1 / share_latest)

e23 <- bail_series %>% filter(str_starts(series, "Breach-of-bail offences recorded"))
e23_2023 <- e23$n[e23$year == 2023]; e23_2025 <- e23$n[e23$year == 2025]
e23_2026 <- e23$n[e23$year == latest]
e23_is_max <- e23_2026 == max(e23$n)

vp  <- bail_series %>% filter(str_starts(series, "Police bail"))
mcb <- bail_series %>% filter(str_starts(series, "Magistrates"))

w  <- court_sexage %>% filter(sex == "Women", age == "All ages")
m  <- court_sexage %>% filter(sex == "Men",   age == "All ages")
a  <- court_sexage %>% filter(sex == "All applicants", age == "All ages")
w25 <- w$refusal[w$year == 2025]; w26 <- w$refusal[w$year == latest]
m25 <- m$refusal[m$year == 2025]; m26 <- m$refusal[m$year == latest]
yw  <- court_sexage %>% filter(sex == "Women", age == "18–24")
apps_growth <- chg(a$applications[a$year == 2025], a$applications[a$year == latest])

cb <- court_breach
nob_2024 <- cb$refusal[cb$year == 2024 & cb$breach == "No breach bail offence"]
nob_2026 <- cb$refusal[cb$year == latest & cb$breach == "No breach bail offence"]

ind_share <- court_ind %>% filter(year == latest) %>%
  summarise(s = applications[status == "Aboriginal and/or Torres Strait Islander"] /
                applications[status == "Total"]) %>% pull(s)

ly <- lga_year %>% filter(year == latest)
state_share <- share_latest
top_share   <- ly %>% slice_max(justice_share, n = 1)
n_third     <- sum(ly$justice_share >= 0.25)
melb_share  <- ly$justice_share[ly$lga == "Melbourne"]
region_share <- ly %>% group_by(region) %>%
  summarise(share = sum(justice) / sum(all_off)) %>% arrange(desc(share))
# ---------------------------------------------------------------------------
# 5. SHARED VISUAL SYSTEM
# Okabe–Ito colour-blind-safe palette; line dash and marker shape are used as
# redundant cues so no chart relies on colour alone.
# ---------------------------------------------------------------------------
pal <- list(accent = "#D55E00", blue = "#0072B2", green = "#009E73",
            orange = "#E69F00", purple = "#CC79A7", sky = "#56B4E9",
            ink = "#1f2328", muted = "#5b6168", grid = "#e9e9e9", band = "#f3e3d6")
region_cols <- c("North West Metro" = pal$blue, "Eastern" = pal$orange,
                 "Southern Metro" = pal$green, "Western" = pal$purple)
font_base <- list(family = "Source Sans 3, Helvetica, Arial, sans-serif", size = 14, color = pal$ink)

axis_style <- function(title, ...) list(
  title = list(text = title, standoff = 12, font = list(size = 13)),
  gridcolor = pal$grid, zeroline = FALSE, linecolor = "#bbbbbb", ticks = "outside",
  tickcolor = "#bbbbbb", tickfont = list(size = 12), automargin = TRUE, ...)
year_axis <- function(years, title = "Financial year (July–June)", ...) {
  axis_style(title, tickmode = "array", tickvals = years, ticktext = fy(years), tickangle = 0, ...)
}
# plotly needs customdata as a list of per-point rows (a bare matrix is dropped)
cd <- function(...) unname(asplit(cbind(...), 1))
btn <- function(label, method, args) list(label = label, method = method, args = args)

# frame(): applies the common layout — title + subtitle, toggle buttons,
# legend and source note positioned in pixels so nothing overlaps.
frame <- function(p, height, title, subtitle, source, buttons = NULL,
                  t = NULL, b = 190, l = 80, r = 30, legend = NULL,
                  button_px = 14, extra_annotations = list(), ...) {
  if (is.null(t)) t <- if (is.null(buttons)) 100 else 135
  h <- height - t - b
  if (is.null(legend)) legend <- list(orientation = "h", x = 0, xanchor = "left",
                                      y = -70 / h, yanchor = "top", font = list(size = 13))
  p <- p %>% layout(
    font = font_base, paper_bgcolor = "white", plot_bgcolor = "white",
    hoverlabel = list(font = list(family = font_base$family, size = 13), align = "left"),
    margin = list(l = l, r = r, t = t, b = b),
    title = list(text = paste0("<b>", title, "</b><br><span style='font-size:14px;color:#5b6168'>",
                               subtitle, "</span>"),
                 x = 0, xanchor = "left", xref = "paper",
                 y = 1, yanchor = "top", yref = "container", pad = list(t = 14),
                 font = list(size = 19, family = font_base$family)),
    legend = legend, ...)
  p <- plotly_build(p)
  src <- list(text = source, xref = "paper", yref = "paper", x = 0, y = 0,
              xanchor = "left", yanchor = "top", yshift = -(b - 62), showarrow = FALSE,
              align = "left", font = list(size = 11, color = pal$muted))
  p$x$layout$annotations <- c(p$x$layout$annotations, extra_annotations, list(src))
  if (!is.null(buttons)) {
    p$x$layout$updatemenus <- list(list(
      type = "buttons", direction = "right", showactive = TRUE, active = 0,
      x = 0, xanchor = "left", y = 1 + button_px / h, yanchor = "bottom",
      pad = list(r = 4, t = 0), bgcolor = "#f6f5f2", bordercolor = "#c9c9c9",
      font = list(size = 13), buttons = buttons))
  }
  p$height <- height
  p %>% config(displaylogo = FALSE, responsive = TRUE,
               modeBarButtonsToRemove = c("lasso2d", "select2d", "autoScale2d"),
               toImageButtonOptions = list(format = "png", scale = 2, filename = "csa_bail_chart"))
}

# Accessible wrapper: a <figure> with an aria-label, a screen-reader summary
# of the key pattern, and a visible caption carrying the full source note
chart_figure <- function(widget, label, caption, sr_text) {
  tags$figure(class = "chart", role = "figure", `aria-label` = label,
              widget,
              tags$p(class = "sr-only", sr_text),
              tags$figcaption(HTML(caption)))
}

# Law-change dates expressed on the financial-year axis
# (a point at x = 2024 represents the year ending 30 June 2024)
date_to_x <- function(d) {
  d <- as.Date(d)
  yr_end <- as.numeric(format(d, "%Y")) + ifelse(as.numeric(format(d, "%m")) > 6, 1, 0)
  yr_end - as.numeric(as.Date(paste0(yr_end, "-06-30")) - d) / 365.25
}
repeal_x    <- date_to_x("2024-03-25")
reinstate_x <- date_to_x("2025-03-26")
c1 <- div_year %>% filter(div_code != "F") %>%
  mutate(label = recode(str_remove(division, "^[A-E] "),
                        "Property and deception offences" = "Property & deception",
                        "Public order and security offences" = "Public order & security",
                        "Justice procedures offences" = "Justice procedures"))
c1_cols <- c("Justice procedures" = pal$accent, "Crimes against the person" = pal$blue,
             "Property & deception" = pal$orange, "Drug offences" = pal$green,
             "Public order & security" = pal$purple)
c1_dash <- c("Justice procedures" = "solid", "Crimes against the person" = "dot",
             "Property & deception" = "dash", "Drug offences" = "dashdot",
             "Public order & security" = "longdash")
labs1 <- names(c1_cols)

p1 <- plot_ly()
for (mode in c("index", "n")) {
  for (lb in labs1) {
    d <- c1 %>% filter(label == lb)
    hero <- lb == "Justice procedures"
    p1 <- p1 %>% add_trace(
      x = d$year, y = if (mode == "index") d$index else d$n,
      type = "scatter", mode = if (hero) "lines+markers+text" else "lines+markers",
      name = lb, legendgroup = lb, showlegend = (mode == "index"), visible = (mode == "index"),
      line = list(color = c1_cols[[lb]], width = if (hero) 4 else 2, dash = c1_dash[[lb]]),
      marker = list(color = c1_cols[[lb]], size = if (hero) 8 else 5),
      opacity = if (hero) 1 else 0.8,
      text = if (hero) ifelse(d$year == latest, paste0("<b>", if (mode == "index") round(d$index) else comma(d$n), "</b>"), "") else NULL,
      textposition = "top center", textfont = list(color = pal$accent, size = 13),
      customdata = cd(fy(d$year), comma(d$n), round(d$index)),
      hovertemplate = paste0("<b>", lb, "</b><br>%{customdata[0]}<br>%{customdata[1]} offences<br>",
                             "Index: %{customdata[2]} (", fy(first), " = 100)<extra></extra>"))
  }
}
vis_index <- rep(c(TRUE, FALSE), each = length(labs1))

p1 <- frame(p1, height = 620,
  title = paste0("Justice procedures offences: +", pct(justice_growth), " in a decade"),
  subtitle = paste0("Recorded offences in Victoria by offence division, ", fy(first), " to ", fy(latest)),
  source = paste0("Source: Crime Statistics Agency (2026a), LGA Recorded Offences, Table 02. Division F 'Other offences' (under 1% of offences) omitted:<br>",
                  "COVID-19 public-health breaches peaked at ", comma(covid_peak$n), " in ", fy(covid_peak$year), ", another case of new law creating new recorded crime."),
  buttons = list(
    btn(paste0("Growth since ", fy(first)), "update",
        list(list(visible = vis_index, showlegend = vis_index),
             list("yaxis.title.text" = paste0("Index (", fy(first), " = 100)"), "shapes[0].visible" = TRUE))),
    btn("Number of offences", "update",
        list(list(visible = !vis_index, showlegend = !vis_index),
             list("yaxis.title.text" = "Offences recorded per year", "shapes[0].visible" = FALSE)))),
  xaxis = year_axis(first:latest, range = c(first - 0.4, latest + 0.4)),
  yaxis = axis_style(paste0("Index (", fy(first), " = 100)"), tickformat = ",d"),
  shapes = list(list(type = "line", x0 = first - 0.4, x1 = latest + 0.4, y0 = 100, y1 = 100,
                     line = list(color = "#8c8c8c", width = 1, dash = "dot"))),
  hovermode = "closest")

chart_figure(p1, "Line chart of recorded offences by offence division, 2016-17 to 2025-26",
  paste0("<b>Chart 1.</b> Each line shows how one offence division changed relative to ", fy(first),
         ". Justice procedures offences (solid line) rose ", pct(justice_growth), "; all other offences combined rose ",
         pct(other_growth), ". Their share of all recorded offences moved from ", pct(share_first), " to ", pct(share_latest),
         ". Offences at justice institutions, in unincorporated Victoria and at unknown locations are excluded because CSA does not publish them by LGA."),
  paste0("Justice procedures offences grew from ", comma(sy$justice[sy$year == first]), " to ",
         comma(sy$justice[sy$year == latest]), ", the fastest growth of any offence division."))
s_cols  <- c(pal$accent, pal$blue, pal$green)
s_sym   <- c("circle", "square", "diamond")
s_names <- unique(bail_series$series)
s_short <- c("Recorded offences (police)", "Police bail decisions", "Court bail applications")
ns <- length(s_names)

p2 <- plot_ly()
for (mode in c("n", "index")) {
  for (i in seq_along(s_names)) {
    d <- bail_series %>% filter(series == s_names[i])
    p2 <- p2 %>% add_trace(
      x = d$year, y = if (mode == "n") d$n else d$index,
      type = "scatter", mode = "lines+markers", name = s_names[i],
      legendgroup = s_names[i], showlegend = (mode == "n"), visible = (mode == "n"),
      line = list(color = s_cols[i], width = if (i == 1) 4 else 2.5),
      marker = list(color = s_cols[i], symbol = s_sym[i], size = 9, line = list(color = "white", width = 1)),
      customdata = cd(fy(d$year), comma(d$n), round(d$index)),
      hovertemplate = paste0("<b>", s_short[i], "</b><br>%{customdata[0]}: %{customdata[1]}",
                             "<br>Index (2022–23 = 100): %{customdata[2]}<extra></extra>"))
  }
}
p2 <- frame(p2, height = 640,
  title = "Breach-of-bail numbers fell while the offence was repealed, then rebounded",
  subtitle = paste0("Breach-of-bail activity in three Victorian justice record systems, ", fy(first), " to ", fy(latest)),
  source = paste0("Sources: Crime Statistics Agency (2026a) Table 02, subgroup E23 Breach bail conditions; (2026c) Victoria Police bail tables, Table 04;<br>",
                  "(2026b) Magistrates' Court bail tables, Table 04, available from 2020–21. Counts are offences, decisions and applications, not unique people."),
  b = 230, legend = list(orientation = "v", x = 0, xanchor = "left", y = -70 / (640 - 150 - 230), yanchor = "top", font = list(size = 13)),
  buttons = list(
    btn("Counts", "update", list(list(visible = rep(c(TRUE, FALSE), each = ns), showlegend = rep(c(TRUE, FALSE), each = ns)),
                                 list("yaxis.title.text" = "Number per year"))),
    btn("Index (2022–23 = 100)", "update", list(list(visible = rep(c(FALSE, TRUE), each = ns), showlegend = rep(c(FALSE, TRUE), each = ns)),
                                               list("yaxis.title.text" = "Index (2022–23 = 100)")))),
  xaxis = year_axis(first:latest, range = c(first - 0.4, latest + 0.4)),
  yaxis = axis_style("Number per year", rangemode = "tozero", tickformat = ",d"),
  shapes = list(list(type = "rect", xref = "x", yref = "paper", x0 = repeal_x, x1 = reinstate_x,
                     y0 = 0, y1 = 1, fillcolor = pal$band, opacity = 0.8, line = list(width = 0), layer = "below")),
  extra_annotations = list(list(
    x = (repeal_x + reinstate_x) / 2, y = 0.03, xref = "x", yref = "paper", yanchor = "bottom",
    text = "<b>Offending on bail<br>not a crime</b><br>25 Mar 2024 –<br>26 Mar 2025",
    showarrow = FALSE, font = list(size = 11, color = "#8a3b00"), bgcolor = "rgba(255,255,255,0.7)")),
  hovermode = "closest")

chart_figure(p2, "Line chart of three breach of bail measures with the repeal period shaded",
  paste0("<b>Chart 2.</b> All three series dip during or after the repeal window and rise once the offence returns. Recorded offences: ",
         num(e23_2023), " → ", num(e23_2025), " → ", num(e23_2026),
         ". Police decisions with a breach charge: ", num(vp$n[vp$year == 2023]), " → ", num(vp$n[vp$year == 2025]), " → ",
         num(vp$n[vp$year == latest]), ". Court applications: ", num(mcb$n[mcb$year == 2023]), " → ",
         num(mcb$n[mcb$year == 2025]), " → ", num(mcb$n[mcb$year == latest]), " (", fy(2023), " → ", fy(2025), " → ", fy(latest), ")."),
  "All three breach-of-bail measures fall across 2023-24 and 2024-25 and rise sharply in 2025-26.")
pair_data <- function(y0, y1) {
  lga_year %>% filter(year %in% c(y0, y1)) %>%
    select(year, lga, region, pop, breach_bail, breach_rate) %>%
    pivot_wider(names_from = year, values_from = c(pop, breach_bail, breach_rate)) %>%
    rename(pop = !!paste0("pop_", y1),
           n0 = !!paste0("breach_bail_", y0), n1 = !!paste0("breach_bail_", y1),
           r0 = !!paste0("breach_rate_", y0), r1 = !!paste0("breach_rate_", y1)) %>%
    filter(!is.na(r0), !is.na(r1)) %>%
    mutate(period = paste0(fy(y0), " → ", fy(y1)))
}
pairs <- list(pair_data(2023, 2025), pair_data(2025, latest))
n_fell <- sum(pairs[[1]]$r1 < pairs[[1]]$r0); n_lga1 <- nrow(pairs[[1]])
n_rose <- sum(pairs[[2]]$r1 > pairs[[2]]$r0); n_lga2 <- nrow(pairs[[2]])
reg_rebound <- pairs[[2]] %>% group_by(region) %>%
  summarise(r0 = sum(n0) / sum(pop) * 1e5, r1 = sum(n1) / sum(pop) * 1e5) %>%
  mutate(change = chg(r0, r1)) %>% arrange(desc(change))
big_riser <- pairs[[2]] %>% filter(pop >= 50000) %>% mutate(d = r1 - r0) %>% slice_max(d, n = 1)
regions <- names(region_cols)
nr <- length(regions)
max_r <- max(unlist(map(pairs, ~ c(.x$r0, .x$r1)))) * 1.05
p3 <- plot_ly()
for (k in seq_along(pairs)) {
  for (rg in regions) {
    d <- pairs[[k]] %>% filter(region == rg)
    p3 <- p3 %>% add_trace(
      x = d$r0, y = d$r1, type = "scatter", mode = "markers",
      name = rg, legendgroup = rg, showlegend = (k == 2), visible = (k == 2),
      marker = list(size = d$pop, sizemode = "area", sizeref = 2 * max(lga_year$pop) / (40^2), sizemin = 4,
                    color = region_cols[[rg]], opacity = 0.75, line = list(color = "white", width = 1)),
      customdata = cd(d$lga, comma(d$n0), comma(d$n1), round(d$r0), round(d$r1),
                         comma(round(d$pop, -2)), d$period),
      hovertemplate = paste0("<b>%{customdata[0]}</b> (", rg, ")<br>%{customdata[6]}<br>",
                             "Breach-of-bail offences: %{customdata[1]} → %{customdata[2]}<br>",
                             "Rate per 100,000: %{customdata[3]} → %{customdata[4]}<br>",
                             "Population ≈ %{customdata[5]}<extra></extra>"))
  }
}
p3 <- p3 %>% add_trace(x = c(0, max_r), y = c(0, max_r), type = "scatter", mode = "lines",
                       line = list(color = "#8c8c8c", dash = "dot", width = 1.5),
                       hoverinfo = "skip", showlegend = FALSE, name = "No change")
axis_titles <- list(
  list(x = paste0("Rate per 100,000 residents, ", fy(2023), " (before repeal)"),
       y = paste0("Rate, ", fy(2025), " (repeal in force)")),
  list(x = paste0("Rate per 100,000 residents, ", fy(2025), " (repeal in force)"),
       y = paste0("Rate, ", fy(latest), " (offence reinstated)")))
mk_vis <- function(k) c(rep(k == 1, nr), rep(k == 2, nr), TRUE)
mk_btn <- function(k, label) btn(label, "update",
  list(list(visible = mk_vis(k), showlegend = c(rep(k == 1, nr), rep(k == 2, nr), FALSE)),
       list("xaxis.title.text" = axis_titles[[k]]$x, "yaxis.title.text" = axis_titles[[k]]$y)))

p3 <- frame(p3, height = 700,
  title = "Breach-of-bail rates by LGA, before and after each law change",
  subtitle = "Each bubble is an LGA, sized by population. Above the dotted line = rate rose; below = rate fell",
  source = paste0("Source: Crime Statistics Agency (2026a), LGA Recorded Offences, Tables 01–02. Rate = E23 breach-of-bail offences ÷ population × 100,000;<br>",
                  "population derived from CSA's published count and rate. Justice institutions and unincorporated Victoria excluded."),
  buttons = list(mk_btn(2, paste0("Reinstatement: ", fy(2025), " → ", fy(latest))),
                 mk_btn(1, paste0("Repeal: ", fy(2023), " → ", fy(2025)))),
  xaxis = axis_style(axis_titles[[2]]$x, range = c(0, max_r)),
  yaxis = axis_style(axis_titles[[2]]$y, range = c(0, max_r)),
  extra_annotations = list(
    list(x = max_r * 0.2, y = max_r * 0.92, text = "<b>ROSE</b> ↑", showarrow = FALSE,
         font = list(color = pal$muted, size = 13)),
    list(x = max_r * 0.88, y = max_r * 0.08, text = "<b>FELL</b> ↓", showarrow = FALSE,
         font = list(color = pal$muted, size = 13))),
  hovermode = "closest")

chart_figure(p3, "Bubble scatter of LGA breach of bail rates before and after each law change",
  paste0("<b>Chart 3.</b> Default view: the reinstatement year. ", n_rose, " of ", n_lga2,
         " LGAs recorded a higher breach-of-bail rate in ", fy(latest), " than in ", fy(2025),
         ". Change in rate per head by police region: ",
         paste0(reg_rebound$region, " ", ifelse(reg_rebound$change > 0, "+", ""), pct(reg_rebound$change), collapse = "; "), "."),
  paste0("In the repeal period ", n_fell, " of ", n_lga1, " LGAs fall below the diagonal; in the reinstatement year ",
         n_rose, " of ", n_lga2, " rise above it."))
ages <- c("18–24", "25–34", "35–44", "45+", "All ages")
sex_cols <- c("Women" = pal$accent, "Men" = pal$blue, "All applicants" = "#8c8c8c")
sex_sym  <- c("Women" = "circle", "Men" = "square", "All applicants" = "circle-open")
yr4 <- sort(unique(court_sexage$year))

panels <- map(seq_along(ages), function(j) {
  pp <- plot_ly()
  for (sx in names(sex_cols)) {
    d <- court_sexage %>% filter(age == ages[j], sex == sx)
    pp <- pp %>% add_trace(
      x = d$year, y = d$refusal, type = "scatter", mode = "lines+markers",
      name = sx, legendgroup = sx, showlegend = (j == 1),
      line = list(color = sex_cols[[sx]], width = if (sx == "All applicants") 1.5 else 3,
                  dash = if (sx == "All applicants") "dot" else "solid"),
      marker = list(color = sex_cols[[sx]], symbol = sex_sym[[sx]], size = 7),
      customdata = cd(fy(d$year), comma(d$refused), comma(d$applications), percent(d$refusal, .1)),
      hovertemplate = paste0("<b>", sx, ", ", ages[j], "</b><br>%{customdata[0]}<br>",
                             "Refused: %{customdata[1]} of %{customdata[2]} (%{customdata[3]})<extra></extra>"))
  }
  pp %>% layout(
    xaxis = list(tickmode = "array", tickvals = range(yr4), ticktext = str_sub(fy(range(yr4)), 3),
                 tickangle = 0, gridcolor = pal$grid, tickfont = list(size = 11),
                 range = c(min(yr4) - 0.5, max(yr4) + 0.5), zeroline = FALSE),
    yaxis = list(tickformat = ".0%", range = c(0, 0.40), gridcolor = pal$grid, zeroline = FALSE,
                 title = if (j == 1) list(text = "Bail applications refused", font = list(size = 13)) else NULL),
    annotations = list(list(text = paste0("<b>", if (ages[j] == "All ages") "All ages" else paste("Aged", ages[j]), "</b>"),
                            x = mean(range(yr4)), y = 1.02, xref = "x", yref = "paper", yanchor = "bottom",
                            showarrow = FALSE, font = list(size = 13))))
})
p4 <- subplot(panels, nrows = 1, shareY = TRUE, titleX = FALSE, margin = 0.015)
p4 <- frame(p4, height = 560, t = 110,
  title = paste0("Bail refusals rose in every age group in ", fy(latest), ", and fastest for women"),
  subtitle = paste0("Share of Magistrates' Court bail applications refused, by age group and sex, ",
                    fy(min(yr4)), " to ", fy(latest)),
  source = paste0("Source: Crime Statistics Agency (2026b), Magistrates' Court bail tables, Table 01. Counts are applications, not unique people; outcomes are<br>",
                  "granted, refused or revoked. Ticks show financial years (20–21 = 2020–21). 18–24 may rarely include people aged 10–17."),
  hovermode = "closest")

chart_figure(p4, "Five small line charts of bail refusal rates by age group and sex",
  paste0("<b>Chart 4.</b> Refusal rate = applications refused ÷ all applications decided (granted, refused or revoked). ",
         "Women's refusal rate rose ", round(100 * (w26 - w25), 1), " percentage points in one year; men's rose ",
         round(100 * (m26 - m25), 1), ". Total applications rose ", pct(apps_growth), " over the same year."),
  paste0("Women's bail refusal rate rose from ", pct(w25, .1), " to ", pct(w26, .1), ", nearly reaching men's rate of ", pct(m26, .1), "."))
d5 <- lga_year %>% filter(year %in% c(first, latest)) %>%
  select(year, lga, region, justice, all_off, justice_share) %>%
  pivot_wider(names_from = year, values_from = c(justice, all_off, justice_share)) %>%
  rename(s0 = !!paste0("justice_share_", first), s1 = !!paste0("justice_share_", latest),
         j0 = !!paste0("justice_", first), j1 = !!paste0("justice_", latest),
         a0 = !!paste0("all_off_", first), a1 = !!paste0("all_off_", latest)) %>%
  filter(!is.na(s0), !is.na(s1)) %>%
  mutate(change = s1 - s0)
ord_share  <- d5 %>% arrange(s1) %>% pull(lga)
ord_change <- d5 %>% arrange(change) %>% pull(lga)
ord_alpha  <- d5 %>% arrange(desc(lga)) %>% pull(lga)

seg_x <- as.vector(rbind(d5$s0, d5$s1, NA)); seg_y <- as.vector(rbind(d5$lga, d5$lga, NA))
p5 <- plot_ly() %>%
  add_trace(x = seg_x, y = seg_y, type = "scatter", mode = "lines",
            line = list(color = "#cfcfcf", width = 2), hoverinfo = "skip", showlegend = FALSE) %>%
  add_trace(x = d5$s0, y = d5$lga, type = "scatter", mode = "markers",
            name = paste0(fy(first), " (all LGAs)"),
            marker = list(symbol = "circle-open", size = 8, color = "#6f6f6f", line = list(width = 1.5)),
            customdata = cd(comma(d5$j0), comma(d5$a0)),
            hovertemplate = paste0("<b>%{y}</b>, ", fy(first), "<br>Justice procedures: %{customdata[0]} of ",
                                   "%{customdata[1]} offences (%{x:.1%})<extra></extra>"))
for (rg in names(region_cols)) {
  d <- d5 %>% filter(region == rg)
  p5 <- p5 %>% add_trace(x = d$s1, y = d$lga, type = "scatter", mode = "markers",
    name = rg, legendgroup = rg,
    marker = list(size = 10, color = region_cols[[rg]], line = list(color = "white", width = 1)),
    customdata = cd(comma(d$j1), comma(d$a1), sprintf("%+.1f", 100 * d$change)),
    hovertemplate = paste0("<b>%{y}</b> (", rg, "), ", fy(latest), "<br>Justice procedures: %{customdata[0]} of ",
                           "%{customdata[1]} offences (%{x:.1%})<br>Change since ", fy(first),
                           ": %{customdata[2]} percentage points<extra></extra>"))
}
h5 <- 1750; t5 <- 235; b5 <- 95; hp5 <- h5 - t5 - b5
p5 <- frame(p5, height = h5, t = t5, b = b5, l = 150, button_px = 118,
  title = "Share of recorded offences that are justice procedures offences, by LGA",
  subtitle = paste0(fy(first), " (hollow) and ", fy(latest), " (filled, coloured by police region). Dotted line = Victoria in ", fy(latest)),
  source = paste0("Source: Crime Statistics Agency (2026a), LGA Recorded Offences, Tables 01–02 (offence division E).<br>",
                  "Offences are counted where recorded; justice institutions and unincorporated Victoria excluded."),
  legend = list(orientation = "h", x = 0, xanchor = "left", y = 1 + 62 / hp5, yanchor = "bottom", font = list(size = 13)),
  buttons = list(
    btn(paste0("Sort: share in ", fy(latest)), "relayout", list(list("yaxis.categoryarray" = ord_share))),
    btn(paste0("Sort: change since ", fy(first)), "relayout", list(list("yaxis.categoryarray" = ord_change))),
    btn("Sort: A–Z", "relayout", list(list("yaxis.categoryarray" = ord_alpha)))),
  xaxis = axis_style("Justice procedures offences as a share of all recorded offences", tickformat = ".0%",
                     side = "top", range = c(0, max(d5$s1, d5$s0) * 1.08)),
  yaxis = list(categoryorder = "array", categoryarray = ord_share, tickfont = list(size = 11),
               title = "", automargin = TRUE, gridcolor = "#f3f3f3", range = c(-0.8, nrow(d5) - 0.2)),
  shapes = list(list(type = "line", xref = "x", yref = "paper", x0 = state_share, x1 = state_share,
                     y0 = 0, y1 = 1, line = list(color = pal$ink, dash = "dot", width = 1))),
  extra_annotations = list(list(x = state_share, y = 0, xref = "x", yref = "paper", yanchor = "top", xanchor = "left",
                                text = paste0(" Victoria ", pct(state_share)), showarrow = FALSE, font = list(size = 12))),
  hovermode = "closest")

chart_figure(p5, "Dumbbell chart of justice procedures offence share by LGA",
  paste0("<b>Chart 5.</b> ", nrow(d5), " LGAs. Share of recorded offences that are justice procedures offences, by police region in ", fy(latest), ": ",
         paste0(region_share$region, " ", pct(region_share$share), collapse = "; "), "."),
  paste0("Justice procedures offences are ", pct(state_share), " of offences statewide; ", n_third,
         " LGAs are at 25% or above, led by ", top_share$lga, " at ", pct(top_share$justice_share), "."))
knitr::kable(tibble::tibble(
  `Dataset (CSA, year ending June 2026)` = c("LGA Recorded Offences (Tables 01, 02)",
                                             "Victoria Police bail data tables (Table 04)",
                                             "Magistrates' Court bail data tables (Tables 01, 02, 04)"),
  `Unit of observation` = c("Offences recorded, by LGA × offence subgroup × financial year",
                            "Initial police bail decisions by breach-offence involvement × year",
                            "Bail applications by outcome × sex × age group (and × Indigenous status, × breach involvement) × year"),
  Period = c(paste(fy(first), "to", fy(latest)), paste(fy(min(vp_breach_raw$Year)), "to", fy(latest)),
             paste(fy(min(mc_age_raw$Year)), "to", fy(latest))),
  Rows = comma(c(nrow(lga_off_raw), nrow(vp_breach_raw), nrow(mc_age_raw))),
  `Used in` = c("Charts 1, 2, 3, 5", "Chart 2", "Charts 2, 4")
))

References

Bail Amendment Act 2023 (Vic). https://www.legislation.vic.gov.au/as-made/acts/bail-amendment-act-2023

Crime Statistics Agency. (2026a). Data tables: LGA recorded offences, year ending June 2026 [Data set]. https://files.crimestatistics.vic.gov.au/2026-09/Data_Tables_LGA_Recorded_Offences_Year_Ending_June_2026.xlsx

Crime Statistics Agency. (2026b). Magistrates’ Court data tables: Bail, year ending June 2026 [Data set]. https://files.crimestatistics.vic.gov.au/2026-09/Magistrates%27_Court_Data_Tables_Bail_Visualisation_Year_Ending_June_2026_1.xlsx

Crime Statistics Agency. (2026c). Victoria Police data tables: Bail, year ending June 2026 [Data set]. https://files.crimestatistics.vic.gov.au/2026-09/Victoria_Police_Data_Tables_Bail_Visualisation_Year_Ending_June_2026_0.xlsx

Okabe, M., & Ito, K. (2008). Color universal design (CUD): How to make figures and presentations that are friendly to colorblind people. https://jfly.uni-koeln.de/color/

O’Kearney, E. (2026, March 12). Inquest finds death of Aboriginal woman Veronica Nelson preventable and prompts calls for reform of Victoria’s bail laws. Human Rights Law Centre. https://www.hrlc.org.au/case-summaries/veronica-nelson-bail-laws/

Parsons, S., & George, M. (2024, February 29). Understanding the new Victorian bail legislation: Adults and First Nations people. Mondaq. https://mondaq.com/australia/crime/1432898/understanding-the-new-victorian-bail-legislation-adults-and-first-nations-people

R Core Team. (2023). R: A language and environment for statistical computing (Version 4.3.3) [Computer software]. R Foundation for Statistical Computing. https://www.R-project.org/

Robinson Gill. (n.d.). Significant amendments to the Bail Act 1977 effective from 26 March 2025. https://www.robinsongill.com.au/resource/significant-amendments-to-the-bail-act-1977-effective-from-26-march-2025/

Sievert, C. (2020). Interactive web-based data visualization with R, plotly, and shiny. Chapman and Hall/CRC. https://plotly-r.com

Victoria Legal Aid. (2024, April 2). Educating the sector on Victoria’s new bail laws. https://legalaid.vic.gov.au/educating-sector-victorias-new-bail-laws

Wickham, H., François, R., Henry, L., Müller, K., & Vaughan, D. (2023). dplyr: A grammar of data manipulation (R package version 1.1.4) [Computer software]. https://CRAN.R-project.org/package=dplyr

Wickham, H., & Bryan, J. (2023). readxl: Read Excel files (R package version 1.4.3) [Computer software]. https://CRAN.R-project.org/package=readxl

Acknowledgements

Data. Contains data from the Crime Statistics Agency Victoria, licensed under Creative Commons Attribution 3.0 Australia (https://creativecommons.org/licenses/by/3.0/au/). The data were aggregated, relabelled and converted to rates and shares as described above. The CSA has not reviewed or endorsed this analysis.

Built in R with readxl, dplyr, tidyr, stringr, purrr, scales, plotly and htmltools. Rendered with R Markdown.