suppressPackageStartupMessages({
library(readxl)
library(dplyr)
library(tidyr)
library(stringr)
library(purrr)
library(ggplot2)
library(plotly)
library(crosstalk)
library(sf)
library(scales)
})
(i)Loads all packages that are referenced in the report - do not print out any startup messages. (ii)Data cleaning and data reshaping is done using dplyr, tidyr, readr, stringr and purrr. (iii)ggplot2 creates a set of charts, plotly turns them into an interactive visualization (web page), and crosstalk connects them together. (iv)sf used to work with shapes of maps, while scales formats numbers as percentages, commas.
# One colour per outcome, used in every chart (Okabe-Ito: colour-blind safe)
status_cols <- c("Charges laid" = "#0072B2",
"No charges laid" = "#999999",
"Unsolved" = "#D55E00")
(i)A named list matching each outcome to one colour. (ii)Every chart uses it, so blue, grey and orange always mean the same thing. (iii)These are Okabe-Ito colours, which stay distinguishable for colour-blind readers.
theme_story <- function(base_size = 12) {
theme_minimal(base_size = base_size) +
theme(panel.grid.minor = element_blank(),
panel.grid.major.x = element_blank(),
axis.title = element_text(colour = "grey30"),
axis.text = element_text(colour = "grey30"),
strip.text = element_text(face = "bold", hjust = 0),
legend.position = "top",
legend.title = element_blank())
}
(i)Develops a chart style that makes the chart look different from ggplot default. (ii)See the examples working on theme_minimal(), and then remove the minor and vertical gridlines to reduce clutter. (iii)Makes axis text dark grey, facet titles bold and left-aligned, and puts the legend on top.
src_csa <- "Source: Crime Statistics Agency Victoria (2026), year ending June."
src_both <- paste("Sources: Crime Statistics Agency Victoria (2026);",
"Australian Bureau of Statistics (2024).")
(i)Takes raw data in the form of text on a line below each chart. (ii)This has been implemented in charts with and without ABS data, which are placed in tabs to be named src_csa and src_both respectively. (iii)These are written once, and stay the same across all entries.
# ggplotly() drops subtitles, captions and legend settings, so set them here.
# legend: "top" (one short row), "right" (long labels) or "none"
finish_plotly <- function(p, title, subtitle, source, legend = "top",
legend_title = "", legend_y = 1, top = 115, bottom = 100) {
leg <- switch(legend,
top = list(orientation = "h", x = 0, y = legend_y, yanchor = "bottom"),
right = list(orientation = "v", x = 1.02, y = 1, yanchor = "top"),
none = list())
leg$title <- list(text = legend_title, font = list(size = 12))
p |>
layout(
title = list(text = paste0("<b>", title, "</b><br>",
"<span style='font-size:13px;color:#555'>",
subtitle, "</span>"),
x = 0, xanchor = "left", yanchor = "top",
y = 1 - 34 / (p$height %||% 500)),
margin = list(t = top, b = bottom, r = 20),
showlegend = legend != "none",
legend = leg,
font = list(family = "Lato, Helvetica, Arial, sans-serif")
) |>
add_annotations(text = source, x = 0, y = 0, xref = "paper", yref = "paper",
xanchor = "left", yanchor = "top", yshift = -(bottom - 28),
showarrow = FALSE, font = list(size = 11, color = "#555")) |>
config(displaylogo = FALSE,
modeBarButtonsToRemove = c("lasso2d", "autoScale2d"))
}
(i)ggplotly() doesn’t add subtitles or captions, so this function does! (ii)The switch() function will place the legend on top, on the right, or remove it from the figure. (iii)layout(): Supplies a bold title and a grey subtitle in the top / left, sets the margins and sets the font. (iv)add_annotations() adds source line below the chart, and config() removes the plotly logo and unwanted buttons.
# Downloaded from the Crime Statistics Agency (links in References)
data_dir <- "/Users/akashkamuju/Desktop/untitled folder/data/raw"
lga_file <- file.path(data_dir, "Data_Tables_LGA_Criminal_Incidents_Year_Ending_June_2026.xlsx")
state_file <- file.path(data_dir, "Data_Tables_Criminal_Incidents_Visualisation_Year_Ending_June_2026.xlsx")
(i)Points R to your two files. (ii)file.path() joins the folder and file name. (iii)If you move the files later, just change data_dir.
# "Local Government Area" -> "local_government_area"
clean_names <- function(x) {
x |>
str_to_lower() |>
str_replace_all("[^a-z0-9]+", "_") |>
str_remove_all("^_|_$")
}
(i)Normalises headers such as ‘Local Government Area’ to ‘local_government_area’. (ii)No spaces cased names are easier to type and these won’t mess up your code.
# Finds the row holding the column headers (the first row with a "Year" cell)
find_header_row <- function(path, sheet, max_rows = 15) {
top <- suppressMessages(
read_excel(path, sheet = sheet, col_names = FALSE, n_max = max_rows,
.name_repair = "minimal")
)
if (nrow(top) == 0) return(NA_integer_)
is_header <- apply(top, 1, function(r) {
any(str_detect(str_trim(as.character(r)), regex("^year$", ignore_case = TRUE)),
na.rm = TRUE)
})
hit <- which(is_header)
if (length(hit)) hit[1] else NA_integer_
}
(i)Not all tables begin at row 1; some include a title row at the top of the table. (ii)This finds the first row with the data “Year”, then reads the top 15 rows. (iii)That row number indicates to R the beginning of the real table.
# Lists every sheet in both workbooks and the columns each one holds
catalogue_workbook <- function(path) {
map_dfr(excel_sheets(path), function(s) {
hr <- find_header_row(path, s)
cols <- if (is.na(hr)) character(0) else
names(suppressMessages(read_excel(path, sheet = s, skip = hr - 1, n_max = 0)))
tibble(path = path, sheet = s, header_row = hr, columns = list(clean_names(cols)))
})
}
catalogue <- bind_rows(catalogue_workbook(lga_file), catalogue_workbook(state_file))
(i)excel_sheets() returns a list of all the sheets, e.g. “Table 01” and “Table 02”. (ii)On each of these sheets, it writes the row header and the header for the col names. (iii)catalogue – Table of all sheets of both files. It is then automatically used in the next chunks to find the correct chunks.
# Picks the most detailed sheet that has all the columns we need
pick_sheet <- function(must_have) {
hits <- catalogue |> filter(map_lgl(columns, ~ all(must_have %in% .x)))
if (nrow(hits) == 0) return(NULL)
hits |> slice_max(lengths(columns), n = 1, with_ties = FALSE)
}
# Reads one sheet, tidies column names, drops footnote and "Total" rows
read_csa_sheet <- function(sheet_row) {
message("Using ", basename(sheet_row$path), " -> ", sheet_row$sheet)
suppressMessages(
read_excel(sheet_row$path, sheet = sheet_row$sheet, skip = sheet_row$header_row - 1)
) |>
rename_with(clean_names) |>
mutate(year = suppressWarnings(as.integer(year))) |>
filter(!is.na(year)) |>
filter(if_all(where(is.character),
~ is.na(.x) | !str_detect(.x, regex("^total\\b", ignore_case = TRUE))))
}
(i)pick_sheet() returns a sheet with all of the columns that you need, starting with the most detailed one. (ii)read_csa_sheet() reads that sheet from the header line, and cleans the names. (iii)It removes footnotes and ‘Total’ rows to prevent any numbers being counted twice. (iv)Prints the sheet number used so you can see.
# Standardises outcome labels and puts them in a fixed order
tidy_status <- function(df) {
df |>
mutate(
charge_status = str_squish(charge_status),
charge_status = case_when(
str_detect(charge_status, regex("^unsolved", ignore_case = TRUE)) ~ "Unsolved",
str_detect(charge_status, regex("^no charges", ignore_case = TRUE)) ~ "No charges laid",
str_detect(charge_status, regex("^charges", ignore_case = TRUE)) ~ "Charges laid",
TRUE ~ charge_status
),
charge_status = factor(charge_status, levels = names(status_cols)),
incidents_recorded = suppressWarnings(as.numeric(incidents_recorded))
)
}
# Not real councils: kept in state totals, left out of council charts
non_lga <- regex("justice institutions|unincorporated", ignore_case = TRUE)
(i)Changes the label of the different CSA components to be the same as your colour names. (ii)Saving in the order: Charges laid, No charges laid, Unsolved. (iii)Ensures that incident counters are numeric. (iv)There are two rows indicated as “non_lga” that are not real councils.
lga_sheet <- pick_sheet(c("year", "local_government_area", "charge_status",
"incidents_recorded"))
if (is.null(lga_sheet)) stop("No sheet with council + charge status found")
lga_status <- read_csa_sheet(lga_sheet) |>
tidy_status() |>
rename(lga = local_government_area, incidents = incidents_recorded) |>
filter(!str_detect(lga, non_lga)) |>
pool_share(year, lga)
## Using Data_Tables_LGA_Criminal_Incidents_Year_Ending_June_2026.xlsx -> Table 05
(i)Reads and clears away council, outcome and incident count sheet. (ii)Trims out any rows which are not council records and makes the column names shorter. (iii)As a result, ‘shares for each year’ for each council are given for each year and the main table for charts 3, 4 and 5. (iv)Prints a line under the chunk stating that it is using such and such (this works as a check).
offence_sheet <- pick_sheet(c("year", "offence_division", "offence_subdivision",
"charge_status", "incidents_recorded"))
if (is.null(offence_sheet)) {
offence_sheet <- pick_sheet(c("year", "offence_division", "charge_status",
"incidents_recorded"))
}
if (is.null(offence_sheet)) stop("No sheet with offence type + charge status found")
offence_raw <- read_csa_sheet(offence_sheet) |>
tidy_status() |>
rename(incidents = incidents_recorded)
## Using Data_Tables_Criminal_Incidents_Visualisation_Year_Ending_June_2026.xlsx -> Table 04
offence_cols <- intersect(c("offence_division", "offence_subdivision"), names(offence_raw))
offence_status <- offence_raw |>
pool_share(year, across(all_of(offence_cols)))
(i)Seeks in detail first and then general if not in detail. (ii)offence_cols retains all of the columns in the offence sheet. (iii)This gives us outcome shares/rewarded offences by year and offence type, which will be helpful with charts 1 and 2.
rate_sheet <- catalogue |>
filter(path == lga_file,
map_lgl(columns, ~ "local_government_area" %in% .x &&
any(str_detect(.x, "^rate_per_100_000")) &&
!"charge_status" %in% .x &&
!"offence_division" %in% .x)) |>
slice_min(lengths(columns), n = 1, with_ties = FALSE)
lga_rates <- if (nrow(rate_sheet) == 1) {
read_csa_sheet(rate_sheet) |>
rename(lga = local_government_area) |>
rename_with(~ "rate_per_100k", matches("^rate_per_100_000")) |>
filter(!str_detect(lga, non_lga)) |>
mutate(rate_per_100k = suppressWarnings(as.numeric(rate_per_100k)))
} else NULL
## Using Data_Tables_LGA_Criminal_Incidents_Year_Ending_June_2026.xlsx -> Table 01
(i)Finds the sheet that shows each council’s total crime rate per 100,000 people. (ii)Renames that column to rate_per_100k. Removes any rows that are not, for councils. (iii)If there is no sheet chart 4 calculates the rate using ABS population data instead.
abs_file <- file.path(data_dir, "abs_lga_vic_2021.geojson")
if (!file.exists(abs_file)) {
abs_url <- paste0(
"https://geo.abs.gov.au/arcgis/rest/services/Hosted/",
"LGA_ABS_SEIFACS_2024_Apr/FeatureServer/0/query",
"?where=", URLencode("lga_code_2021 LIKE '2%'", reserved = TRUE), # 2 = Victoria
"&outFields=lga_code_2021,lga_name_2021,irsd_score,irsd_decile_au,erp_p_20",
"&outSR=4326&maxAllowableOffset=0.002&f=geojson")
download.file(abs_url, abs_file, mode = "wb", quiet = TRUE)
}
(i)Downloads council shapes and disadvantage scores from the ABS into the data/raw folder. (ii)LIKE ‘2%’ keeps councils only. (iii)It asks for five columns and simplified shapes so the file stays small.
abs_lga <- st_read(abs_file, quiet = TRUE) |>
filter(!str_detect(lga_name_2021, "Unincorporated|No usual|Migratory"))
#Match council names
# CSA and ABS spell some councils differently ("Colac-Otway" vs "Colac Otway",
# "Bayside" vs "Bayside (Vic.)") and Moreland was renamed Merri-bek in 2022.
match_key <- function(x) {
x |>
str_remove("\\s*\\(.*\\)$") |>
str_replace("^Moreland$", "Merri-bek") |>
str_to_lower() |>
str_remove_all("[^a-z]")
}
abs_lga <- abs_lga |> mutate(key = match_key(lga_name_2021))
unmatched <- setdiff(match_key(unique(lga_status$lga)), abs_lga$key)
if (length(unmatched)) warning("Councils not matched to ABS: ",
paste(unmatched, collapse = ", "))
(i)Creates a shared key so that the names of the CSA and the ABS council are the same. (ii)Removes brackets changes the name to Moreland makes everything lowercase. Removes spaces and dashes. (iii)Tells you if any council still does not match.
# Pool the last three years so small councils (few incidents) aren't noisy
latest_year <- max(lga_status$year)
window <- (latest_year - 2):latest_year
window_lab <- paste0(min(window), "–", max(window))
(i)Picks the recent three years for charts 2 to 4. (ii)Combining years stops councils from shifting around too much. (iii)Window_lab creates a label such, as “2024–2026”.
state_unsolved <- lga_status |>
filter(year %in% window) |>
pool_share() |>
filter(charge_status == "Unsolved") |>
pull(share)
(i)Victoria’s overall unsolved share for the window appears as one number. (ii)Victoria’s overall unsolved share for the window is also used as the dashed benchmark line, in charts 2 and 4.
if (!is.null(lga_rates)) {
lga_summary <- lga_summary |>
left_join(lga_rates |>
filter(year %in% window) |>
group_by(lga) |>
summarise(rate_per_100k = mean(rate_per_100k, na.rm = TRUE)),
by = "lga")
}
if (!"rate_per_100k" %in% names(lga_summary)) lga_summary$rate_per_100k <- NA_real_
(i)Adds each council’s average crime rate over the window. (ii)If the rate table wasn’t found, it creates an empty column for the next chunk to fill.
lga_summary <- lga_summary |>
left_join(st_drop_geometry(abs_lga) |>
select(key, irsd_decile_au, erp_p_20), by = "key") |>
mutate(
# fallback if the CSA rate table wasn't found: incidents per year / ABS population
rate_per_100k = coalesce(rate_per_100k,
incidents_total / length(window) / erp_p_20 * 1e5),
disadvantage = cut(irsd_decile_au, breaks = c(0, 2, 4, 6, 8, 10),
labels = c("1–2 (most)", "3–4", "5–6", "7–8", "9–10 (least)"))
)
(i)Each council’s ABS disadvantage decile and population are used. (ii)Coalesce() fills any missing crime rate using ABS population. (iii)Cut() groups the 10 deciles into 5 colour bands, for chart 4.
lga_summary <- lga_summary |>
mutate(hover = paste0("<b>", lga, "</b>",
"<br>Unsolved: ", percent(unsolved_share, 0.1),
"<br>Incidents ", window_lab, ": ", comma(incidents_total),
"<br>Crime rate: ", comma(rate_per_100k, 1), " per 100,000",
"<br>Disadvantage decile: ", irsd_decile_au, " (1 = most)"))
(i)The text that appears when someone moves their mouse over a
council, in charts 3 and 4. (ii) is used for text and
creates a
new line. Percent() and comma() help make the numbers easier to
read.
# Statewide outcomes, last fully settled year
lga_status |>
filter(year == latest_year - 1) |>
pool_share() |>
mutate(share = scales::percent(share, 0.1))
## # A tibble: 3 × 4
## charge_status incidents total share
## <fct> <dbl> <dbl> <chr>
## 1 Charges laid 170274 479810 35.5%
## 2 No charges laid 60513 479810 12.6%
## 3 Unsolved 249023 479810 51.9%
# Total incidents in the 3-year window
sum(lga_summary$incidents_total)
## [1] 1352621
# Councils with the highest and lowest unsolved share
lga_summary |> arrange(desc(unsolved_share)) |> select(lga, unsolved_share) |> head(3)
## # A tibble: 3 × 2
## lga unsolved_share
## <chr> <dbl>
## 1 Maribyrnong 0.642
## 2 Port Phillip 0.640
## 3 Boroondara 0.635
lga_summary |> arrange(unsolved_share) |> select(lga, unsolved_share) |> head(3)
## # A tibble: 3 × 2
## lga unsolved_share
## <chr> <dbl>
## 1 Southern Grampians 0.238
## 2 Horsham 0.267
## 3 Swan Hill 0.275
Most of us assume that once a crime is reported, someone is eventually held responsible. Victoria’s own data tells a different story. Of the 479,810 criminal incidents police recorded in the year to June 2025, only 35.5% ended with charges laid. More than half, 51.9%, were still unsolved.
That gap isn’t spread evenly. Whether a case is solved depends a lot on what kind of crime it was, and on where in Victoria it happened. Over 2024–2026, 64.2% of incidents in Maribyrnong went unsolved, compared with 23.8% in Southern Grampians.
This report follows what happens after a crime is reported. It starts with the statewide picture over ten years, then narrows to the crimes most likely to go unsolved, the councils where outcomes are worst, and whether that comes down to how much crime an area has or how disadvantaged it is. The final chart lets you look up your own council.
The data comes from the Crime Statistics Agency, which records the outcome of every reported incident across Victoria’s 79 councils, combined with Australian Bureau of Statistics measures of socio-economic disadvantage.
title_1 <- "Most property crimes go unsolved. Almost no drug crimes do"
subtitle_1 <- "Share of criminal incidents by outcome, by offence type, year ending June"
(i)The chart’s title and subtitle are kept in one place so they are easy to edit. (ii)Once you see the chart change title_1 to the finding itself.
c1 <- offence_status |>
group_by(year, offence_division, charge_status) |>
summarise(incidents = sum(incidents), .groups = "drop") |>
group_by(year, offence_division) |>
mutate(share = incidents / sum(incidents)) |>
ungroup() |>
mutate(division = str_remove(offence_division, "^[A-Z]\\s+"),
hover = paste0("<b>", division, ", ", year, "</b><br>",
charge_status, ": ", percent(share, 0.1),
"<br>(", comma(incidents), " incidents)"))
(i)Works out how much each outcome contributes each year and for each type of offence. (ii)Removes the letter code so that “A Crimes against the person” becomes “Crimes, against the person”. (iii)Creates the hover text.
shade <- tibble(xmin = latest_year - 0.5, xmax = latest_year + 0.5)
p1 <- ggplot(c1, aes(year, share, colour = charge_status, group = charge_status)) +
geom_rect(data = shade, inherit.aes = FALSE,
aes(xmin = xmin, xmax = xmax, ymin = 0, ymax = 1),
fill = "grey92", colour = NA) +
geom_line(linewidth = 0.9) +
geom_point(aes(text = hover), size = 1.2) +
facet_wrap(~ division, ncol = 3) +
scale_colour_manual(values = status_cols) +
scale_y_continuous(labels = percent, limits = c(0, 1), breaks = c(0, .5, 1)) +
scale_x_continuous(breaks = pretty_breaks(4)) +
labs(x = NULL, y = "Share of incidents") +
theme_story()
## Warning in geom_point(aes(text = hover), size = 1.2): Ignoring unknown
## aesthetics: text
(i)Draws the grey band, then one line per outcome. (ii)facet_wrap() makes 6 small panels, one per offence type. (iii)Applies your colours and your theme.
r
ggplotly(p1, tooltip = "text", height = 560) |>
finish_plotly(title_1, subtitle_1,
paste(src_csa, "Grey band = latest year; some cases still open."),
legend_y = 1.07, top = 135)
The chance that a crime gets solved depends a lot on the type of crime. In the year to June 2025, 71.9% of property and deception offences, such as theft, burglary and property damage, remained unsolved. By contrast, only 3.2% of drug offences and 11.5% of justice procedure offences, such as breaching a court order, were unsolved. These kinds of crimes are usually caught by police as they occur, so there is usually a person to charge.
Property crime also takes up most of the numbers. About two out of every three incidents recorded in 2025 were property or deception offences, so what happens to these cases mostly determines the total. Outcomes for crimes against the person are also getting worse: the percentage that ended in charges fell from 58.8% in 2017 to 48.4% in 2025.
The grey band marks the most recent year. Outcomes are recorded at the moment the data is extracted, so many of these cases are still being investigated. Statewide, 55.1% of incidents in the latest year are unsolved, compared with 51.9% in the year before, and that gap will narrow as investigations finish. That is why the later charts combine three years instead of relying on one. Chart description: six small line charts, one for each offence type, showing the share of incidents each year from 2017 to 2026 that ended with charges laid (blue), no charges laid (grey) or still unsolved (orange). Property and deception offences have the highest unsolved share; drug offences the lowest.
title_2 <- "Nearly four in five thefts and burglaries go unsolved"
subtitle_2 <- paste0("Share of incidents still unsolved, ", window_lab,
" combined. Dot size = number of incidents")
level <- if ("offence_subdivision" %in% names(offence_status)) "offence_subdivision" else "offence_division"
c2 <- offence_status |>
filter(year %in% window) |>
rename(offence = all_of(level)) |>
pool_share(offence_division, offence) |>
filter(charge_status == "Unsolved") |>
mutate(offence_lab = str_remove(offence, "^[A-Z][0-9]*\\s+"),
division = str_remove(offence_division, "^[A-Z]\\s+"),
offence_lab = reorder(offence_lab, share),
hover = paste0("<b>", offence_lab, "</b><br>", division,
"<br>Unsolved: ", percent(share, 0.1),
"<br>Incidents: ", comma(total)))
p2 <- ggplot(c2, aes(share, offence_lab)) +
geom_vline(xintercept = state_unsolved, linetype = "dashed", colour = "grey50") +
geom_segment(aes(x = 0, xend = share, yend = offence_lab), colour = "grey85") +
geom_point(aes(size = total, colour = division, text = hover), alpha = 0.9) +
scale_x_continuous(labels = percent, limits = c(0, 1), expand = expansion(c(0, .02))) +
scale_size_area(max_size = 9, guide = "none") +
scale_colour_viridis_d(option = "D", end = 0.85) +
labs(x = "Share unsolved", y = NULL) +
theme_story() +
theme(panel.grid.major.y = element_blank())
## Warning in geom_point(aes(size = total, colour = division, text = hover), :
## Ignoring unknown aesthetics: text
ggplotly(p2, tooltip = "text", height = 720) |>
add_annotations(x = state_unsolved, y = 1, yref = "paper", xanchor = "left",
text = paste0(" All crime: ", percent(state_unsolved, 1)),
showarrow = FALSE, font = list(size = 11, color = "#555")) |>
finish_plotly(title_2, subtitle_2, src_csa, legend = "right",
legend_title = "Offence type", top = 100)
Theft is the most common offence in Victoria. Between 2024 and 2026 there were 552,104 theft incidents, and 77.4% of them remain unsolved. Burglary is even harder to solve, with 78.3% of burglaries unsolved. These two offences alone account for most of Victoria’s unsolved crime.
At the other end are crimes that police usually find on their own: drug use and possession, with a 3.4% unsolved rate, weapons offences at 4.2% and breaches of orders at 4.7%. Assault, the most common crime against a person with 139,687 incidents, is well below the average at 17.1%. Public security offences have the highest unsolved rate at 84.7%, but the number is very low, with only 554 cases in total..
Chart description: a dot plot listing 26 offence types from least to most unsolved. Each dot’s position shows the share unsolved, its size shows the number of incidents, and its colour shows the broad offence group. A dashed line marks the average for all crime, 52%.
Click a council on the map or the scatter plot to highlight it in charts 3, 4 and 5. Double-click to clear.
title_3 <- "Crime in parts of Melbourne is more than twice as likely to go unsolved as in regional Victoria"
subtitle_3 <- paste0("Share of criminal incidents unsolved by council, ", window_lab, " combined")
(i)The italic line tells readers how to use the linked charts. (ii)Title_3 states the Melbourne vs finding.
map_df <- abs_lga |>
inner_join(lga_summary |> select(-irsd_decile_au, -erp_p_20), by = "key") |>
select(lga, unsolved_share, hover, geometry)
sd_map <- highlight_key(map_df, ~lga, group = "Find your council")
p3 <- ggplot(sd_map) +
geom_sf(aes(fill = unsolved_share, text = hover), colour = "white", linewidth = 0.2) +
scale_fill_gradient(low = "#FDEBDD", high = "#A63603", labels = percent,
name = "Unsolved") +
theme_void(base_size = 12) +
theme(legend.position = "right")
## Warning in layer_sf(geom = GeomSf, data = data, mapping = mapping, stat = stat,
## : Ignoring unknown aesthetics: text
ggplotly(p3, tooltip = "text", height = 600) |>
style(hoveron = "fills") |>
layout(xaxis = list(visible = FALSE), yaxis = list(visible = FALSE)) |>
highlight(on = "plotly_click", off = "plotly_doubleclick", opacityDim = 0.25,
selected = attrs_selected(line = list(color = "black", width = 2))) |>
finish_plotly(title_3, subtitle_3, src_both, legend = "none", top = 100)
The eight councils with the highest unsolved shares are all in metropolitan Melbourne: Maribyrnong (64.2%), Port Phillip (64.0%), Boroondara (63.5%), Yarra (63.4%), Stonnington (63.2%), Glen Eira (62.8%), Whitehorse (62.1%) and Merri-bek (62.0%). The eight lowest unsolved shares all belong to regional councils. In Southern Grampians, 23.8% of incidents remained unsolved, followed by Horsham (26.7%), Swan Hill (27.5%) and Warrnambool (27.5%).
Chart description: a map of Victoria’s 79 councils shaded from light orange (fewer unsolved incidents) to dark orange (more). The darkest areas cluster around inner and middle Melbourne; most regional councils are lighter.
title_4 <- "Wealthier councils have more unsolved crime, not less"
subtitle_4 <- paste0("Each dot is a council, ", window_lab,
". Size = incidents. Colour = socio-economic disadvantage (ABS SEIFA)")
sd_scatter <- highlight_key(lga_summary, ~lga, group = "Find your council")
p4 <- ggplot(sd_scatter, aes(rate_per_100k, unsolved_share)) +
geom_hline(yintercept = state_unsolved, linetype = "dashed", colour = "grey50") +
geom_point(aes(size = incidents_total, colour = disadvantage, text = hover), alpha = 0.85) +
scale_x_log10(labels = comma) +
scale_y_continuous(labels = percent) +
scale_size_area(max_size = 8, guide = "none") +
scale_colour_viridis_d(option = "D", end = 0.85, na.value = "grey60") +
labs(x = "Criminal incidents per 100,000 people (log scale)",
y = "Share unsolved") +
theme_story()
## Warning in geom_point(aes(size = incidents_total, colour = disadvantage, :
## Ignoring unknown aesthetics: text
(i)Each dot is a council. X is crime rate y is share size is incidents colour is disadvantage band. (ii)Scale_x_log10() spreads the dots out so very high-crime councils don’t squash the rest. (iii)Geom_hline() draws the dashed average.
ggplotly(p4, tooltip = "text", height = 560) |>
highlight(on = "plotly_click", off = "plotly_doubleclick", opacityDim = 0.2) |>
add_annotations(x = 1, xref = "paper", xanchor = "right", y = state_unsolved,
yanchor = "bottom", text = "Victoria overall", showarrow = FALSE,
font = list(size = 11, color = "#555")) |>
finish_plotly(title_4, subtitle_4, src_both, legend = "right",
legend_title = "Disadvantage<br>decile (ABS)", top = 100)
You might expect councils with more crime to struggle more to solve it. They do not: a council’s crime rate is barely related to its unsolved share (correlation −0.14). Disadvantage shows a clearer pattern, but in the opposite direction to what many would guess. In the least disadvantaged councils (deciles 9–10), 56.6% of incidents went unsolved, compared with 40.8% in councils in deciles 3–4, even though those wealthier councils have about half the crime rate.
This doesn’t mean that being rich makes crime harder to solve. Seventeen of the 25 least disadvantaged councils are in metropolitan Melbourne, so the pattern likely shows the difference between the city and the country seen in chart 3 more than it shows income.
Chart description: a scatter plot where each dot represents a council. The horizontal axis displays crimes per 100,000 people on a log scale, and the vertical axis shows the share of incidents unsolved. The size of each dot shows the number of incidents and its colour shows the ABS disadvantage decile. Lighter dots, meaning less disadvantaged councils, are mostly above the Victorian average line.
Type your council’s name in the box, or click a line.
title_5 <- "Since 2017, unsolved crime has risen in 51 of Victoria's 79 councils"
subtitle_5 <- "Share of incidents unsolved each year: every council (grey) against Victoria (black)"
trend <- lga_status |>
filter(charge_status == "Unsolved", !is.na(share)) |>
mutate(hover = paste0("<b>", lga, ", ", year, "</b><br>Unsolved: ",
percent(share, 0.1), "<br>Incidents: ", comma(total)))
state_trend <- lga_status |>
pool_share(year) |>
filter(charge_status == "Unsolved") |>
mutate(hover = paste0("<b>Victoria, ", year, "</b><br>Unsolved: ", percent(share, 0.1)))
sd_trend <- highlight_key(trend, ~lga, group = "Find your council")
p5 <- ggplot() +
geom_rect(data = shade, aes(xmin = xmin, xmax = xmax,
ymin = 0, ymax = max(trend$share)),
fill = "grey92") +
geom_line(data = sd_trend, aes(year, share, group = lga, text = hover),
colour = "grey75", linewidth = 0.4) +
geom_line(data = state_trend, aes(year, share, group = 1, text = hover),
colour = "black", linewidth = 1.3) +
scale_y_continuous(labels = percent) +
scale_x_continuous(breaks = pretty_breaks(6)) +
labs(x = NULL, y = "Share unsolved") +
theme_story()
## Warning in geom_line(data = sd_trend, aes(year, share, group = lga, text =
## hover), : Ignoring unknown aesthetics: text
## Warning in geom_line(data = state_trend, aes(year, share, group = 1, text =
## hover), : Ignoring unknown aesthetics: text
ggplotly(p5, tooltip = "text", height = 520) |>
highlight(on = "plotly_click", off = "plotly_doubleclick", selectize = TRUE,
color = status_cols[["Unsolved"]], opacityDim = 0.6) |>
finish_plotly(title_5, subtitle_5,
paste(src_csa, "Grey band = latest year; some cases still open."),
legend = "none", top = 100)
Statewide the unsolved crime rate dropped to 39.0% in 2021 during the COVID lockdown years. Then it went up again reaching 51.9% by 2025. That is higher than it was in 2017, when the rate was 47.9%. When comparing 2017 with 2025 the unsolved crime share increased in 51 councils. Decreased in 28. The City of Melbourne saw a rise from 44.0% to 60.4%. Mansfield had the trend dropping from 43.7% to 32.9%. Smaller councils often show swings from year, to year because they record fewer incidents overall.
Chart description:I look at a line chart that shows one grey line for each council and one thick black line for Victoria.The line chart displays the share of incidents each year from 2017 to 2026.If you search for or click on a council the line, for that council turns orange..
The type of crime matters most. Property crime, which makes up two in every three incidents, mostly goes unsolved, while offences that police find themselves almost always lead to charges. Where you live matters too: councils in Melbourne have more than 60% unsolved, while some regional councils have less than 30%. It is not just about how much crime a place has. With the unsolved share now higher than its 2017 level, the question is not just how much crime Victoria records, but how much of that crime ever gets an outcome.
Outcomes. The Crime Statistics Agency records each incidents status when the data is extracted from the Victoria Police database.Charges laid means least one charge was laid.No charges laid means incident was resolved some way, such, as a caution or warning.Unsolved means no offender had been proceeded against at the time of extraction.
Recent years look worse. Because results are recorded at a moment the most recent year, which goes up to June 2026 has had the shortest time for investigations to be completed. Its unsolved percentage, which is 55.1% will decrease as more cases are solved. The recent year is shown in grey in chart 1 and chart 5.
Combining years. Charts 2 3 and 4 combine data from 2024 to 2026 so that small councils with incidents do not show big swings. Since this time period includes the recent year the shares shown are a little higher than the final numbers will be. This happens for every council and every type of offence. The effect is not the same, for all of them.
Data used. Council outcomes come from CSA LGA Table 05. Council outcomes also use crime rates, per 100,000 from LGA Table 01. Council outcomes draw on offence outcomes from the Table 04. “Justice Institutions and Immigration Facilities” and “Unincorporated Vic” are left out of the council charts. Included in the offence totals.
Matching councils. CSA and ABS name some councils differently (for example, Moreland is now Merri-bek, and ABS adds “(Vic.)” to Bayside, Kingston and Latrobe). All 79 councils were matched.
Disadvantage. The ABS Index of Relative Socio-economic Disadvantage (IRSD) uses data from the 2021 Census to rank areas across the country. Decile 1 represents the most disadvantaged areas. In Victoria, only three councils are in the lowest two deciles, meaning they are among the most socio-economically disadvantaged in the nation.
Australian Bureau of Statistics. (2024). Local Government Areas: ABS social and economic data for the Australian Climate Service [Data set]. https://geo.abs.gov.au/arcgis/rest/services/Hosted/LGA_ABS_SEIFACS_2024_Apr/FeatureServer/0
Cheng, J., & Sievert, C. (2023). https://cran.r-project.org/web/packages/crosstalk/index.html
Crime Statistics Agency. (2026). Criminal incidents by LGA – Year ending June 2026 [Data set]. DataVic. https://discover.data.vic.gov.au/dataset/criminal-incident
Crime Statistics Agency. (2026). Criminal incidents – Year ending June 2026 [Data set]. DataVic. https://discover.data.vic.gov.au/dataset/criminal-incident
Crime Statistics Agency. (n.d.). Glossary and data dictionary. Retrieved October 28, 2026, from https://www.crimestatistics.vic.gov.au/about-the-data/glossary-and-data-dictionary
Pebesma, E. (2018). Simple features for R: Standardized support for spatial vector data. The R Journal, 10(1), 439–446. https://journal.r-project.org/articles/RJ-2018-009/index.html
R Core Team. (2026). R: A language and environment for statistical computing (Version 4.5.3) [Computer software]. R Foundation for Statistical Computing. https://www.r-project.org
Sievert, C. (2020). Interactive web-based data visualization with R, plotly, and shiny. Chapman and Hall/CRC. https://plotly-r.com
Wickham, H. (2016). ggplot2: Elegant graphics for data analysis. Springer-Verlag. https://ggplot2.tidyverse.org