This is not a story about someone who “should have known better.” The first time it happened, I was only 15 years old. At that time I was anxious to earn some money, so I paid RM200 for a typing job that turned out not to exist. Six years later, when I was 21, a stranger on Instagram messaged me shortly after a break-up. Over the next three months he became my friend and then my “business mentor”, until one day a video call showed me that the face talking to me had been generated by AI. Along the way, I also paid a deposit for a Mazda to a seller who used JPJ’s name, asked my mother for a loan to finish a shopping “task”, and answered a phone call about a prize that ended with money being taken from my bank account. Altogether, across the eight encounters that I can remember clearly, I lost about RM99,200. What surprised me most when I looked back was that the need they used against me was different every single time.
To protect other people, I have left out any details that could identify individuals, accounts or services. In this report, my own memories carry the story, while the official statistics show the bigger pattern across Malaysia. In addition, I did not analyse the nationality of the scammers, because an online identity does not prove where a person is really from.
How to read: Each marker is one encounter, placed by my approximate age and the money involved. Colour shows the scam type (the same colours are used in Figures 2, 3 and 5) and a filled marker means I lost money. Hover over a marker to read what happened. Click a marker to highlight that scam type here and in Figure 5; double-click to reset.
incidents <- incidents %>%
mutate(
plot_amount = pmax(amount_rm, 100), # RM0 (declined) is drawn at the floor of the log axis
# hover text: what happened in each encounter
tooltip = paste0(
"<b>Encounter ", incident_no, " · ", life_stage, " (about ", approx_age, ")</b><br>",
"The need they used: ", entry_need, "<br>",
"Official category: ", pdrm_category, "<br>",
"Money at stake: ", amount_note, "<br>",
"Outcome: ", outcome, "<br><br>",
str_wrap(detail, 70) %>% str_replace_all("\n", "<br>")
)
)
# Shaded background bands for each life stage
stage_bands <- tibble(
stage = c("Secondary school", "Pre-university & Foundation", "University"),
x0 = c(14.5, 17.5, 20.5),
x1 = c(17.5, 20.5, 22.5),
fill = c("rgba(212,161,58,0.10)", "rgba(45,111,135,0.08)", "rgba(185,87,44,0.08)")
)
# Linked data: the key is the scam category and the group is shared with Figure 5,
# so clicking a marker here highlights the same category there
incidents_shared <- crosstalk::SharedData$new(incidents, key = ~pdrm_category, group = "scam-family")
# Colour = scam category, symbol = outcome (filled = money lost, hollow = no loss)
p1 <- plot_ly(
incidents_shared,
x = ~approx_age,
y = ~plot_amount,
type = "scatter",
mode = "markers+text",
color = ~pdrm_category,
colors = family_colours,
symbol = ~outcome,
symbols = outcome_symbols,
marker = list(size = 20, line = list(width = 2.5), opacity = 0.95),
text = ~paste0("#", incident_no),
textposition = "top center",
textfont = list(size = 13, color = "#263442"),
hovertext = ~tooltip,
hoverinfo = "text"
) %>%
layout(
autosize = TRUE,
# life-stage bands drawn behind the markers
shapes = lapply(seq_len(nrow(stage_bands)), function(i) list(
type = "rect", xref = "x", yref = "paper",
x0 = stage_bands$x0[i], x1 = stage_bands$x1[i], y0 = 0, y1 = 1,
fillcolor = stage_bands$fill[i], line = list(width = 0), layer = "below"
)),
# life-stage labels above the bands, then three callouts for the key encounters
annotations = c(
lapply(seq_len(nrow(stage_bands)), function(i) list(
x = (stage_bands$x0[i] + stage_bands$x1[i]) / 2, y = 1.02, xref = "x", yref = "paper",
text = stage_bands$stage[i], showarrow = FALSE, font = note_style, yanchor = "bottom"
)),
list(
list(x = 19, y = log10(50000), xref = "x", yref = "y", ax = -95, ay = -10,
text = "RM50,000 into a crypto<br>project that vanished",
showarrow = TRUE, arrowhead = 0, arrowcolor = "#526273", font = note_style, align = "left"),
list(x = 18, y = log10(10000), xref = "x", yref = "y", ax = -75, ay = 30,
text = "RM10,000 deposit for a car<br>that was never for sale",
showarrow = TRUE, arrowhead = 0, arrowcolor = "#526273", font = note_style, align = "left"),
list(x = 21, y = log10(1000), xref = "x", yref = "y", ax = -95, ay = 70,
text = "Three months of chat;<br>the transfer failed,<br>the face was AI",
showarrow = TRUE, arrowhead = 0, arrowcolor = "#526273", font = note_style, align = "left")
)
),
xaxis = modifyList(axis_base, list(title = list(text = "My approximate age", standoff = 14), dtick = 1, range = c(14.5, 22.5), zeroline = FALSE, showgrid = FALSE)),
# log scale so RM200 and RM50,000 both fit; RM0 is drawn at the bottom
yaxis = modifyList(axis_base, list(title = list(text = "Money at stake (RM, log scale)", standoff = 14), type = "log",
range = c(1.85, 5.05),
tickvals = c(100, 300, 1000, 3000, 10000, 30000, 100000),
ticktext = c("0 / <RM100", "300", "1,000", "3,000", "10,000", "30,000", "100,000"))),
legend = list(orientation = "h", x = 0, y = -0.18, xanchor = "left", yanchor = "top", title = list(text = ""), font = list(size = 12.5), entrywidth = 240),
margin = list(l = 95, r = 30, t = 48, b = 120),
paper_bgcolor = "#FFFFFF",
plot_bgcolor = "#FFFFFF",
font = chart_font,
hoverlabel = hover_style
) %>%
config(displaylogo = FALSE, responsive = TRUE, modeBarButtonsToRemove = modebar_remove) %>%
# click a marker to highlight its scam category (here and in Figure 5); double-click to reset
highlight(on = "plotly_click", off = "plotly_doubleclick", opacityDim = 0.25, selected = attrs_selected(showlegend = FALSE), persistent = FALSE)
# hide each callout when its marker is switched off in the legend,
# and reset the highlight when the legend is used
p1 <- htmlwidgets::onRender(p1, "
function(el, x) {
var owners = {'RM50,000': 'Non-existent investment<br />Money lost',
'RM10,000': 'E-commerce crime<br />Money lost',
'Three months': 'Love scam<br />Transfer blocked'};
function sync() {
var shown = {};
el.data.forEach(function(t) {
if (t.visible !== 'legendonly' && t.visible !== false) shown[t.name] = true;
});
var changed = false;
var anns = (el.layout.annotations || []).map(function(a) {
for (var k in owners) {
if (a.text && a.text.indexOf(k) === 0) {
var v = !!shown[owners[k]];
if (a.visible !== v) changed = true;
return Object.assign({}, a, {visible: v});
}
}
return a;
});
if (changed) Plotly.relayout(el, {annotations: anns});
}
el.on('plotly_restyle', sync);
// clicking the legend clears any marker highlight (also in Figure 5),
// so the categories shown through the legend are not left faded
var selection = new crosstalk.SelectionHandle('scam-family');
function clearHighlight() {
// wait until plotly has finished its own single/double-click handling, and only act if something is highlighted
setTimeout(function() {
if (selection.value && selection.value.length) selection.set(null);
}, 400);
}
el.on('plotly_legendclick', clearHighlight);
el.on('plotly_legenddoubleclick', clearHighlight);
}")
accessible_plot(p1, "Timeline of eight scam encounters between ages 15 and 22. Horizontal position is approximate age, vertical position is money at stake on a log scale, colour is the scam family and filled versus hollow markers show whether money was lost. Shaded bands mark secondary school, pre-university and university. Clicking a marker highlights the same scam family in Figure 5.")The amounts grew as I got older, and so did the scammers’ patience. At 15 the bait was a simple job: it was over in about a day and cost me RM200. At 16 the scammers first refunded my purchases and paid me commission, and only then did the order grow to about RM6,000, far more than I could afford. My largest loss came at 19, about RM50,000 into a cryptocurrency project that disappeared, and the second largest was RM30,000 for a group tour at 22. By 21 the bait was no longer a job or a product but a person, and building that trust took three months. The one time I lost nothing (#5) was not because I saw through it: the transfer simply failed.
These colours carry through Figures 2, 3 and 5. Blue marks buying, selling and task scams, which PDRM calls e-commerce crime and which are closest to DOSM’s online fraud category. Orange is non-existent investment fraud, gold is telecommunication crime, and pink would have been a love scam if the money had gone through.
| Encounter | Life stage | Age (approx.) | Entry need | Official category | Outcome | Money at stake |
|---|---|---|---|---|---|---|
| 1 | Form 3 | 15 | Work | E-commerce crime | Money lost | RM200 membership fee |
| 2 | Secondary school | 16 | Income | E-commerce crime | Money lost | About RM6,000 spent on the final bundle |
| 3 | Pre-university | 18 | Mobility | E-commerce crime | Money lost | RM10,000 deposit |
| 4 | Foundation | 19 | Investment | Non-existent investment | Money lost | About RM50,000 invested |
| 5 | University Year 3 | 21 | Connection | Love scam | Transfer blocked | About RM1,000 of ETH bought as a test |
| 6 | University Year 3 | 21 | Reward | Telecommunication crime | Money stolen | About RM3,000 taken from my account |
| 7 | University | 21 | Opportunity | Non-existent investment | Declined before paying | No money paid |
| 8 | University | 22 | Travel | E-commerce crime | Money lost | About RM30,000 paid for the tour |
How to read: Each line follows one scam type over time: 2021–2024 for non-existent investment fraud and love scams, and 2022–2024 for online fraud, a category my DOSM sources only report from 2022. Moving right means more cases; moving up means more money lost. Hover over a point for the year, cases, total loss and average loss per case.
national_trends <- national_trends %>%
group_by(scam_type) %>%
mutate(
tooltip = paste0(
"<b>", scam_type, "</b><br>",
"Year: ", year, "<br>",
"Cases: ", comma(cases), "<br>",
"Recorded losses: RM", number(loss_million, accuracy = 0.1), "m<br>",
"Average loss per case: RM", comma(round(average_loss_rm))
),
# only label the first and last year of each line, to keep the chart clean
year_label = ifelse((year == min(year) | year == max(year)) & scam_type != "Love scam", as.character(year), ""),
# first-year label goes above-left so it is not read as the label of the next point
label_position = ifelse(year == min(year), "top left", "bottom right")
) %>%
ungroup()
# 2024 points, used to anchor the callouts
end_points <- national_trends %>% filter(year == 2024)
# Connected scatter: each line is one scam type moving from 2022 to 2024
p2 <- plot_ly(
national_trends,
x = ~cases,
y = ~loss_million,
split = ~scam_type,
color = ~scam_type,
colors = palette_main,
type = "scatter",
mode = "lines+markers+text",
text = ~year_label,
textposition = ~label_position,
textfont = list(size = 11.5, color = "#526273"),
hovertext = ~tooltip,
hoverinfo = "text",
line = list(width = 4),
marker = list(size = 12),
showlegend = FALSE
) %>%
layout(
autosize = TRUE,
annotations = list(
list(x = end_points$cases[end_points$scam_type == "Non-existent investment"],
y = end_points$loss_million[end_points$scam_type == "Non-existent investment"],
ax = -150, ay = 20, text = "<b>Non-existent investment</b><br>My encounter #4 (Foundation) and<br>the business 'branch' I declined (#7)<br>sit in this family",
showarrow = TRUE, arrowhead = 0, arrowcolor = "#B9572C", font = list(size = 12.5, color = "#B9572C"), align = "left"),
list(x = end_points$cases[end_points$scam_type == "Online fraud"],
y = end_points$loss_million[end_points$scam_type == "Online fraud"],
ax = 75, ay = -55, text = "<b>Online fraud</b><br>Two-thirds as many cases,<br>less than one-sixth of the loss",
showarrow = TRUE, arrowhead = 0, arrowcolor = "#2D6F87", font = list(size = 12.5, color = "#2D6F87"), align = "left"),
list(x = end_points$cases[end_points$scam_type == "Love scam"],
y = end_points$loss_million[end_points$scam_type == "Love scam"],
ax = 30, ay = -95, text = "<b>Love scam</b><br>Fewest cases. My encounter #5<br>would have counted here<br>had the transfer gone through",
showarrow = TRUE, arrowhead = 0, arrowcolor = "#985177", font = list(size = 12.5, color = "#985177"), align = "left")
),
xaxis = modifyList(axis_base, list(title = list(text = "Recorded cases", standoff = 14), rangemode = "tozero", tickformat = ",d")),
yaxis = modifyList(axis_base, list(title = list(text = "Recorded losses (RM millions)", standoff = 14), rangemode = "tozero")),
margin = list(l = 86, r = 40, t = 45, b = 72),
paper_bgcolor = "#FFFFFF",
plot_bgcolor = "#FFFFFF",
font = chart_font,
hoverlabel = hover_style
) %>%
config(displaylogo = FALSE, responsive = TRUE, modeBarButtonsToRemove = modebar_remove)
accessible_plot(p2, "Connected scatter plot tracing online fraud, non-existent investment fraud and love scams in Malaysia from 2022 to 2024. Recorded cases are on the horizontal axis and recorded losses in millions of ringgit on the vertical axis. Non-existent investment fraud rises steeply to RM848.6 million in 2024.")In 2024, non-existent investment fraud caused RM848.6 million in recorded losses, more than six times the loss from online fraud, from only about 1.5 times as many cases. My own encounters suggest why. A shopping task takes a few hundred ringgit and is over within days, but an “investment” is built on weeks of fake returns, so by the time it collapses the victim has put in far more. My single biggest loss, about RM50,000 (#4), was an investment scam, and both investment approaches I received (#4 and #7) belong to the scam type that grew fastest: from 3,195 cases and RM245.1 million in 2021 to 6,337 cases and RM848.6 million in 2024.
| Scam family | Year | Cases | Loss (RM million) | Average loss per case (RM) |
|---|---|---|---|---|
| Love scam | 2021 | 1,460 | 56.7 | 38,865 |
| Love scam | 2022 | 792 | 56.3 | 71,053 |
| Love scam | 2023 | 935 | 43.9 | 46,974 |
| Love scam | 2024 | 770 | 45.9 | 59,575 |
| Non-existent investment | 2021 | 3,195 | 245.1 | 76,700 |
| Non-existent investment | 2022 | 3,266 | 219.8 | 67,311 |
| Non-existent investment | 2023 | 5,386 | 472.2 | 87,664 |
| Non-existent investment | 2024 | 6,337 | 848.6 | 133,917 |
| Online fraud | 2022 | 2,633 | 94.2 | 35,793 |
| Online fraud | 2023 | 3,522 | 123.1 | 34,962 |
| Online fraud | 2024 | 4,253 | 130.1 | 30,582 |
How to read: Tick the boxes to choose which scam types to compare. Each bar is the share of victims within that scam type, so types of very different sizes can be compared fairly. The shaded band marks the ages at which I was targeted.
# Shared data so the checkbox filter can show or hide scam types
age_shared <- crosstalk::SharedData$new(
age_2024,
key = ~paste(scam_type, age_group, sep = "-"),
group = "age-profile"
)
# Checkbox filter shown above the chart
age_filter <- crosstalk::filter_checkbox(
id = "age_filter",
label = "Choose scam families",
sharedData = age_shared,
group = ~scam_type,
inline = TRUE
)
# Share of online-fraud victims aged 21-30, used in the callout
online_peak <- age_2024 %>% filter(scam_type == "Online fraud", age_group == "21–30") %>% pull(share)
# Grouped bars: share of victims in each age group, within each scam type
p3 <- plot_ly(
age_shared,
height = 576, # same height as the other four figures (knitr cannot size widgets inside bscols)
x = ~age_group,
y = ~share,
color = ~scam_type,
colors = palette_main,
type = "bar",
hovertext = ~tooltip,
hoverinfo = "text"
) %>%
layout(
autosize = TRUE,
barmode = "group",
# shaded band over the 15-20 and 21-30 groups (my age range)
shapes = list(list(
type = "rect", xref = "x", yref = "paper",
x0 = -0.5, x1 = 1.5, y0 = 0, y1 = 1,
fillcolor = "rgba(212,161,58,0.14)", line = list(width = 0), layer = "below"
)),
annotations = list(
list(
x = 0.5, y = 1.0, xref = "x", yref = "paper",
text = paste0("My age at every encounter: ", my_age_min, "–", my_age_max),
showarrow = FALSE, font = list(size = 12.5, color = "#7A5A12"), yanchor = "bottom"
),
# label beside the online-fraud bar for 21-30 (first of three bars in the group, so its right edge is x = 1 - 0.13)
list(
x = 0.87, y = online_peak, xref = "x", yref = "y", xanchor = "left", yanchor = "top",
text = paste0("<b>Online fraud peaks</b><br>", percent(online_peak, accuracy = 1),
" of its victims;<br>4 of my 8 encounters"),
showarrow = FALSE, align = "left", font = list(size = 12.5, color = "#2D6F87")
)
),
xaxis = modifyList(axis_base, list(title = list(text = "Age group of victims, 2024", standoff = 14), showgrid = FALSE,
range = c(-0.5, 5.5))), # fixed so the callout cannot stretch the axis
yaxis = modifyList(axis_base, list(title = list(text = "Share within scam family", standoff = 14), tickformat = ".0%",
range = c(0, online_peak + 0.05))), # headroom above the tallest bar
legend = list(orientation = "h", x = 0, y = 1.13, xanchor = "left", yanchor = "bottom", title = list(text = ""), font = list(size = 13)),
margin = list(l = 82, r = 25, t = 78, b = 65),
paper_bgcolor = "#FFFFFF",
plot_bgcolor = "#FFFFFF",
font = chart_font,
hoverlabel = hover_style
) %>%
config(displaylogo = FALSE, responsive = TRUE, modeBarButtonsToRemove = modebar_remove) %>%
# The shared data is only for the checkbox filter. Turn off click-highlighting: on grouped bars
# plotly draws the highlight as an extra bar next to the others.
highlight(on = NULL, off = NULL)
# hide the online-fraud callout when online fraud is unticked in the filter
p3 <- htmlwidgets::onRender(p3, "
function(el, x) {
function sync() {
var shown = el.data.some(function(t) {
return t.name === 'Online fraud' && t.visible !== false && t.visible !== 'legendonly' && t.x && t.x.length > 0;
});
var changed = false;
var anns = (el.layout.annotations || []).map(function(a) {
if (a.text && a.text.indexOf('Online fraud peaks') >= 0 && a.visible !== shown) {
changed = true;
return Object.assign({}, a, {visible: shown});
}
return a;
});
if (changed) Plotly.relayout(el, {annotations: anns});
}
el.on('plotly_afterplot', sync);
}")
crosstalk::bscols(
widths = c(12, 12), # filter above the chart, so the chart is full width like the others
age_filter,
accessible_plot(p3, "Grouped interactive bars compare the 2024 age distribution of Malaysian victims across online fraud, non-existent investment fraud and love scams. A shaded band highlights the 15 to 30 age groups, the ages at which the author was targeted. A checkbox filter controls which scam families are shown.")
)Online fraud peaks in the 21–30 age group, the group I was in for four of my eight encounters. Investment fraud and love scams, however, are spread almost evenly from people in their 30s to those over 60. The method that worked on me at 21, months of friendly conversation before any request for money, is not aimed only at the young. It is aimed at anyone who wants to believe the person on the other side of the screen.
| Age group | Online fraud: victims (share) | Non-existent investment: victims (share) | Love scam: victims (share) |
|---|---|---|---|
| 15–20 | 217 (5.1%) | 175 (2.8%) | 23 (3.0%) |
| 21–30 | 1,165 (27.2%) | 1,095 (17.3%) | 130 (16.9%) |
| 31–40 | 805 (18.8%) | 1,390 (21.9%) | 155 (20.1%) |
| 41–50 | 771 (18.0%) | 1,343 (21.2%) | 158 (20.5%) |
| 51–60 | 728 (17.0%) | 1,256 (19.8%) | 140 (18.2%) |
| 61+ | 594 (13.9%) | 1,078 (17.0%) | 164 (21.3%) |
How to read: Each bubble is one state. Further right means more of its online-fraud victims were aged 15–30 (my age range); higher up means more were women. Bubble size shows the number of victims and colour shows the region. The dashed lines are the national averages, so the top-right corner is where victims are most like me. Press play or drag the slider to move through 2022–2024, and click a region in the legend to show or hide it.
# State where my encounters happened (spelled as in DOSM); this bubble gets a dark outline and a label
my_state <- "Sarawak"
# Age groups that cover my age range
young_groups <- c("15–20", "21–30")
# For each state and year: share of online-fraud victims aged 15-30 and share who are women
state_profile <- online_age %>%
filter(state != "Malaysia") %>%
group_by(state, year) %>%
summarise(victims_age = first(total), young = sum(victims[age_group %in% young_groups]), .groups = "drop") %>%
inner_join(online %>% filter(state != "Malaysia") %>% select(state, year, victims, female), by = c("state", "year")) %>%
mutate(
young_share = young / victims_age,
female_share = female / victims,
region = factor(unname(region_lookup[state]), levels = names(region_colours)),
is_me = !is.na(my_state) & state == my_state,
bubble_px = rescale(sqrt(victims), to = c(10, 44)),
tooltip = paste0(
"<b>", state, "</b> (", region, ")<br>",
"Year: ", year, "<br>",
"Online-fraud victims: ", comma(victims), "<br>",
"Aged 15–30: ", percent(young_share, accuracy = 0.1), "<br>",
"Women: ", percent(female_share, accuracy = 0.1),
ifelse(is_me, "<br><i>Where my encounters happened</i>", "")
)
)
# National shares for each year, drawn as dashed reference lines
national_profile <- online_age %>%
filter(state == "Malaysia") %>%
group_by(year) %>%
summarise(young_share = sum(victims[age_group %in% young_groups]) / first(total), .groups = "drop") %>%
inner_join(online %>% filter(state == "Malaysia") %>% transmute(year, female_share = female / victims), by = "year")
# Dashed national reference lines as data (two points per line), so they move with the
# year slider and the Play animation instead of jumping at the end
ref_vertical <- national_profile %>%
tidyr::crossing(point = c("bottom", "top")) %>%
mutate(x = young_share, y = ifelse(point == "bottom", 0.3, 0.68))
ref_horizontal <- national_profile %>%
tidyr::crossing(point = c("left", "right")) %>%
mutate(x = ifelse(point == "left", 0, 0.65), y = female_share)
# frame = year adds the play button and slider; ids keeps each state's bubble the same between years.
# Bubble size is mapped with size = (not inside marker = list()), so plotly keeps each size with
# the right state when it splits the data by region and year. bubble_px is already scaled to
# 10-44 px with area proportional to victims, and sizes = c(10, 44) leaves it unchanged.
p4 <- plot_ly(
state_profile,
x = ~young_share,
y = ~female_share,
frame = ~year,
ids = ~state,
type = "scatter",
mode = "markers",
color = ~region,
colors = region_colours,
legendgroup = ~region,
size = ~bubble_px,
sizes = c(10, 44),
marker = list(sizemode = "diameter", opacity = 0.85, line = list(color = "#FFFFFF", width = 1.2)),
hovertext = ~tooltip,
hoverinfo = "text"
)
# Dark ring and label drawn on top of the state where my encounters happened, so they are never
# hidden behind a bigger bubble and move with the animation. They share the region's legend
# group, so they hide with that region.
if (!is.na(my_state)) {
p4 <- p4 %>%
add_trace(
data = state_profile %>% filter(is_me),
x = ~young_share,
y = ~female_share,
frame = ~year,
ids = ~paste(state, "ring"),
type = "scatter",
mode = "markers",
inherit = FALSE,
marker = list(symbol = "circle-open", size = ~bubble_px + 8, sizemode = "diameter",
color = "#172033", line = list(width = 3)),
legendgroup = unname(region_lookup[my_state]),
showlegend = FALSE,
hoverinfo = "skip"
)
# The label hangs below a point that stays clear of every bubble in all three years, on both
# desktop and phone widths, joined to the ring by a leader line. The line is animated, so it
# follows the ring while the years play.
label_x <- 0.31
label_y <- 0.37
my_leader <- state_profile %>%
filter(is_me) %>%
tidyr::crossing(end = c("ring", "label")) %>%
mutate(x = ifelse(end == "ring", young_share, label_x), y = ifelse(end == "ring", female_share, label_y))
my_label <- state_profile %>% filter(is_me) %>% mutate(x = label_x, y = label_y)
p4 <- p4 %>%
add_trace(data = my_leader, x = ~x, y = ~y, frame = ~year, ids = ~paste("leader", end),
type = "scatter", mode = "lines", inherit = FALSE, showlegend = FALSE, hoverinfo = "skip",
line = list(color = "#172033", width = 1.2),
legendgroup = unname(region_lookup[my_state])) %>%
add_trace(data = my_label, x = ~x, y = ~y, frame = ~year, ids = ~paste("label", state),
type = "scatter", mode = "text", inherit = FALSE, showlegend = FALSE, hoverinfo = "skip",
text = ~paste0("<b>", state, "</b><br>where my encounters happened"),
textposition = "bottom center", textfont = list(size = 12.5, color = "#172033"),
legendgroup = unname(region_lookup[my_state]))
}
p4 <- p4 %>%
add_trace(data = ref_vertical, x = ~x, y = ~y, frame = ~year, ids = ~paste("v", point),
type = "scatter", mode = "lines", inherit = FALSE, showlegend = FALSE, hoverinfo = "skip",
line = list(color = "#9BA9B4", width = 1.2, dash = "dash")) %>%
add_trace(data = ref_horizontal, x = ~x, y = ~y, frame = ~year, ids = ~paste("h", point),
type = "scatter", mode = "lines", inherit = FALSE, showlegend = FALSE, hoverinfo = "skip",
line = list(color = "#9BA9B4", width = 1.2, dash = "dash"))
p4 <- p4 %>%
layout(
autosize = TRUE,
annotations = list(
list(x = 0.99, y = 0.99, xref = "paper", yref = "paper", xanchor = "right", yanchor = "top", showarrow = FALSE,
text = "Younger <b>and</b> more female<br>than the national profile", font = note_style, align = "right"),
list(x = 0.01, y = 0.01, xref = "paper", yref = "paper", xanchor = "left", yanchor = "bottom", showarrow = FALSE,
text = "Older and more male", font = note_style, align = "left")
),
xaxis = modifyList(axis_base, list(title = list(text = "Share of victims aged 15–30", standoff = 14), tickformat = ".0%", range = c(0, 0.65))),
yaxis = modifyList(axis_base, list(title = list(text = "Share of victims who are women", standoff = 14), tickformat = ".0%", range = c(0.3, 0.68))),
legend = list(orientation = "h", x = 0, y = 1.12, xanchor = "left", yanchor = "bottom", title = list(text = ""), font = list(size = 13)),
margin = list(l = 90, r = 30, t = 70, b = 80),
paper_bgcolor = "#FFFFFF",
plot_bgcolor = "#FFFFFF",
font = chart_font,
hoverlabel = hover_style
) %>%
# animation speed and the year slider
animation_opts(frame = 1100, transition = 500, redraw = TRUE) %>%
animation_slider(currentvalue = list(prefix = "Year: ", font = list(size = 16, color = "#263442"))) %>%
config(displaylogo = FALSE, responsive = TRUE, modeBarButtonsToRemove = modebar_remove)
accessible_plot(p4, "Animated bubble chart of Malaysian states from 2022 to 2024. Horizontal position is the share of each state's online-fraud victims aged 15 to 30, vertical position is the share who are women, bubble area is the number of victims and colour is the region. Dashed lines mark the national shares for the selected year.")During every encounter I was a woman aged between 15 and 22. In 2024, about one third of Malaysia’s recorded online-fraud victims were aged 15–30 and about half were women, so a victim like me is ordinary rather than unusual. Sarawak, where my encounters happened, stays within four percentage points of the national average on both measures in every year. States do move between corners from year to year, especially the smaller ones, where a few dozen cases can shift the shares a lot, and these are administrative counts rather than victimisation rates. The scammers who reached me did not need to know where I lived, only what I wanted.
| State/territory grouping | Region | Victims | Aged 15–30 | Women |
|---|---|---|---|---|
| Perlis | Northern | 94 | 43.6% | 50.0% |
| W.P. Kuala Lumpur | Central | 503 | 39.2% | 53.5% |
| Kedah | Northern | 246 | 36.2% | 52.8% |
| Selangor | Central | 776 | 35.8% | 50.4% |
| Perak | Northern | 382 | 34.3% | 47.9% |
| Pahang | East Coast | 301 | 34.2% | 50.8% |
| Sarawak | East Malaysia | 273 | 31.1% | 53.8% |
| Pulau Pinang | Northern | 287 | 30.7% | 51.9% |
| Kelantan | East Coast | 136 | 30.1% | 53.7% |
| Melaka | Southern | 139 | 29.5% | 48.9% |
| Terengganu | East Coast | 136 | 28.7% | 39.0% |
| Johor | Southern | 538 | 25.8% | 50.0% |
| Sabah | East Malaysia | 210 | 24.3% | 45.2% |
| Negeri Sembilan | Central | 259 | 22.8% | 45.2% |
How to read: Each point is one PDRM online-crime category. Press play or drag the slider to move from 2021 to January–November 2025; no official breakdown by category was found for 2024, and 2025 covers only eleven months. Colours match Figure 1; a filled point is a type I have experienced and a hollow point is one I have not. Both axes use a log scale so the smallest and largest categories fit on one chart. Hover for the figures and my matching encounters. If you clicked a marker in Figure 1, the same scam type is already highlighted here; click any point to change it, or double-click to reset.
# Which of my encounters fall into each PDRM category
my_matches <- incidents %>%
group_by(pdrm_category) %>%
summarise(matches = paste0("#", incident_no, collapse = ", "), n = n(), .groups = "drop")
# One row per category and period: 2021-2023 from DOSM (full years), 2025 from PDRM (January-November).
# No official breakdown of these six categories was found for 2024, so the slider skips it.
period_levels <- c("2021", "2022", "2023", "Jan–Nov 2025")
online_crime <- bind_rows(
dosm_online_2021_2023 %>% mutate(period = as.character(year)),
pdrm_2025 %>% transmute(category, cases, loss_rm, year = 2025, period = "Jan–Nov 2025")
) %>%
left_join(my_matches, by = c("category" = "pdrm_category")) %>%
mutate(
period = factor(period, levels = period_levels),
loss_million = loss_rm / 1e6,
average_loss_rm = loss_rm / cases,
encountered = ifelse(is.na(n), "Not encountered (yet)", "I have encountered this"),
encountered = factor(encountered, levels = c("I have encountered this", "Not encountered (yet)")),
# label positions chosen by hand so the category names do not overlap
text_position = case_when(
category == "Telecommunication crime" ~ "middle left",
category == "Love scam" ~ "middle right",
category == "E-commerce crime" ~ "bottom center",
TRUE ~ "top center"
),
tooltip = paste0(
"<b>", category, "</b> (", period, ")<br>",
"Cases: ", comma(cases), "<br>",
"Recorded losses: RM", number(loss_million, accuracy = 0.1), "m",
ifelse(category == "Non-existent investment" & year == 2025, " (PDRM: \"more than\")", ""), "<br>",
"Average loss per case: RM", comma(round(average_loss_rm)), "<br>",
ifelse(is.na(matches), "<i>None of my encounters fit here</i>", paste0("<i>My encounters: ", matches, "</i>"))
)
) %>%
arrange(period, category)
# Animated callout on non-existent investment: it follows the point in every period, so it moves
# with the Play animation instead of appearing at the end. The text sits up-left of the point
# (log axes, so the offset is a ratio: 10^0.3 fewer cases, 10^0.1 less loss). Short labels keep it
# inside the chart on phone screens.
investment_callout <- online_crime %>%
filter(category == "Non-existent investment") %>%
mutate(
label_x = cases / 10^0.3,
label_y = loss_million / 10^0.1,
label = ifelse(
year == 2025,
paste0("<b>Over RM", number(loss_rm / 1e9, accuracy = 0.01), "bn</b><br>in 11 months, the<br>largest loss of all"),
paste0("<b>RM", number(loss_million, accuracy = 0.1), "m</b><br>lost in ", period)
)
)
investment_leader <- investment_callout %>%
tidyr::crossing(end = c("point", "label")) %>%
mutate(x = ifelse(end == "point", cases, label_x), y = ifelse(end == "point", loss_million, label_y))
# Same group as Figure 1, so the two charts highlight together
pdrm_shared <- crosstalk::SharedData$new(online_crime, key = ~category, group = "scam-family")
# Filled point = a category I have experienced, hollow = not yet.
# frame = period adds the play button and slider; ids keeps each category's point the same between periods.
p5 <- plot_ly(
pdrm_shared,
x = ~cases, y = ~loss_million,
frame = ~period, ids = ~category,
type = "scatter", mode = "markers+text",
color = ~category, colors = family_colours,
symbol = ~encountered, symbols = c("I have encountered this" = "circle", "Not encountered (yet)" = "circle-open"),
text = ~category, textposition = ~text_position, textfont = list(size = 13, color = "#263442"),
hovertext = ~tooltip, hoverinfo = "text",
marker = list(size = 19, line = list(width = 2.5)),
showlegend = FALSE
) %>%
add_trace(data = investment_leader, x = ~x, y = ~y, frame = ~period, ids = ~paste("leader", end),
type = "scatter", mode = "lines", inherit = FALSE, showlegend = FALSE, hoverinfo = "skip",
line = list(color = "#B9572C", width = 1.2)) %>%
add_trace(data = investment_callout, x = ~label_x, y = ~label_y, frame = ~period, ids = ~paste("callout", category),
type = "scatter", mode = "markers+text", inherit = FALSE, showlegend = FALSE, hoverinfo = "skip",
marker = list(size = 1, opacity = 0), # invisible marker: plotly only animates text that has a marker
text = ~label, textposition = "middle left", textfont = list(size = 12.5, color = "#B9572C")) %>%
layout(
autosize = TRUE,
annotations = list(
list(x = 0, y = 1.08, xref = "paper", yref = "paper", xanchor = "left", showarrow = FALSE, align = "left",
text = "<b>●</b> family I have encountered <b>○</b> not encountered (yet) colours as in Figure 1",
font = list(size = 13, color = "#526273"))
),
xaxis = modifyList(axis_base, list(title = list(text = "Cases (log scale)", standoff = 14), type = "log", dtick = 1, range = c(2.8, 4.7))),
yaxis = modifyList(axis_base, list(title = list(text = "Recorded losses, RM millions (log scale)", standoff = 14), type = "log", dtick = 1, range = c(1.4, 3.3))),
margin = list(l = 90, r = 60, t = 65, b = 85),
paper_bgcolor = "#FFFFFF",
plot_bgcolor = "#FFFFFF",
font = chart_font,
hoverlabel = hover_style
) %>%
# animation speed and the period slider
animation_opts(frame = 1300, transition = 600, redraw = TRUE) %>%
animation_slider(currentvalue = list(prefix = "Period: ", font = list(size = 16, color = "#263442"))) %>%
config(displaylogo = FALSE, responsive = TRUE, modeBarButtonsToRemove = modebar_remove) %>%
# linked highlighting with Figure 1
highlight(on = "plotly_click", off = "plotly_doubleclick", opacityDim = 0.25, selected = attrs_selected(showlegend = FALSE), persistent = FALSE)
accessible_plot(p5, "Animated scatter plot of six Malaysian online-crime categories in 2021, 2022, 2023 and January to November 2025, with cases on a horizontal log scale and losses in millions of ringgit on a vertical log scale. Four categories the author has personally encountered are filled; non-existent loans and e-financial crime are hollow. Most categories move up and to the right over time; love scams move down.")Between 2021 and 2025, five of the six categories moved up and to the right. Telecommunication crime grew from 6,423 cases in 2021 to 28,698 in the first eleven months of 2025, and losses to non-existent investment fraud rose from RM245.1 million to more than RM1.37 billion. Love scams are the only exception: cases fell from 1,460 to 978. Once the money was transferred, each of my encounters stopped being a job, a seller, a mentor or a friend and became a row in one of these six categories. Four of the six have already reached me. The two that have not, non-existent loans and e-financial crime, seem to be waiting for the next stage of my life, when I may need to borrow or manage more money than I do as a student.
| Period | Category | Cases | Loss (RM million) | Average loss per case (RM) | Personal exposure |
|---|---|---|---|---|---|
| 2021 | E-commerce crime | 9,499 | 73.0 | 7,688 | I have encountered this |
| 2021 | Telecommunication crime | 6,423 | 199.6 | 31,068 | I have encountered this |
| 2021 | Non-existent loans | 4,784 | 50.6 | 10,578 | Not encountered (yet) |
| 2021 | Non-existent investment | 3,195 | 245.1 | 76,700 | I have encountered this |
| 2021 | Love scam | 1,460 | 56.7 | 38,865 | I have encountered this |
| 2021 | E-financial crime | 896 | 73.5 | 81,981 | Not encountered (yet) |
| 2022 | E-commerce crime | 9,258 | 140.0 | 15,121 | I have encountered this |
| 2022 | Telecommunication crime | 7,732 | 321.4 | 41,563 | I have encountered this |
| 2022 | Non-existent investment | 3,266 | 219.8 | 67,311 | I have encountered this |
| 2022 | Non-existent loans | 3,174 | 37.4 | 11,798 | Not encountered (yet) |
| 2022 | E-financial crime | 1,257 | 76.2 | 60,634 | Not encountered (yet) |
| 2022 | Love scam | 792 | 56.3 | 71,053 | I have encountered this |
| 2023 | E-commerce crime | 11,481 | 204.3 | 17,791 | I have encountered this |
| 2023 | Telecommunication crime | 10,368 | 353.6 | 34,104 | I have encountered this |
| 2023 | Non-existent investment | 5,393 | 472.8 | 87,675 | I have encountered this |
| 2023 | Non-existent loans | 4,263 | 40.8 | 9,562 | Not encountered (yet) |
| 2023 | E-financial crime | 2,092 | 104.9 | 50,144 | Not encountered (yet) |
| 2023 | Love scam | 935 | 44.0 | 47,008 | I have encountered this |
| Jan–Nov 2025 | Telecommunication crime | 28,698 | 715.7 | 24,939 | I have encountered this |
| Jan–Nov 2025 | E-commerce crime | 14,881 | 123.7 | 8,313 | I have encountered this |
| Jan–Nov 2025 | Non-existent investment | 9,296 | 1,370.0 | 147,375 | I have encountered this |
| Jan–Nov 2025 | Non-existent loans | 8,029 | 59.1 | 7,361 | Not encountered (yet) |
| Jan–Nov 2025 | E-financial crime | 5,853 | 458.1 | 78,268 | Not encountered (yet) |
| Jan–Nov 2025 | Love scam | 978 | 43.7 | 44,683 | I have encountered this |
data/dosm_online_crime_2021_2023.csv and
data/pdrm_2025_categories.csv, and the code checks that the
six categories add up to the published totals (26,257, 25,479 and 34,532
cases for 2021–2023, and 67,735 for January–November 2025). The 2025
figures cover only eleven months, and PDRM reports the investment loss
as “more than” RM1.37 billion.
Official records count reports, cases, victims and investigation papers. However, they cannot show the losses that were never reported, the fact that the same person may be targeted again and again, or the long process of learning to trust people again. My own experience of being approached around ten times, at different stages of my life, falls exactly into this gap. In fact, only one of my eight encounters was ever reported to the police.
When I look back, I can see that every encounter had the same turning point. At first, the scammers kept their first promise, or the conversation had gone on for so long that checking their story felt like insulting a friend. After that, the real request arrived, whether it was a bigger payment, a photo of my bank card or a transfer in cryptocurrency. Because of this, the rule I follow now is quite simple. If someone I have never met in person asks me for money or bank details, then all the kindness they showed before does not prove anything. In that situation, I either check their story through a channel that they did not give me, or I walk away.
I used ChatGPT (GPT-5.6 Sol; OpenAI, 2026) for limited assistance with identifying relevant official data sources from the Department of Statistics Malaysia (DOSM) and the Royal Malaysia Police (PDRM), reviewing spelling and grammar, suggesting improvements to the clarity and wording of my independently written work, and providing technical guidance on using R.
I independently verified the information against the original sources, reviewed the suggestions, and made the final decisions about my submitted work.
The story was based on my personal experiences with eight scams. I independently decided which experiences to share and what to keep private. The central observation that scammers initially fulfilled small promises before requesting more money was also developed from my own experiences.
Bank Negara Malaysia. (n.d.). Financial scams. Retrieved September 30, 2026, from https://www.bnm.gov.my/faqs/scams
Department of Statistics Malaysia. (2024). Crime statistics, Malaysia, 2024. https://storage.dosm.gov.my/crime/crime_2023.pdf
Department of Statistics Malaysia. (2025, October 16). Crime statistics, Malaysia, 2025 [Data set]. https://www.dosm.gov.my/portal-main/release-content/crime-statistics-malaysia-2025
Department of Statistics Malaysia. (2026, October 16). Crime statistics, Malaysia, 2026 [Press release]. https://www.dosm.gov.my/portal-main/release-content/crime-statistics-malaysia-2026
OpenAI. (2026). ChatGPT (GPT-5.6 Sol) [Large language model]. https://chatgpt.com/
Royal Malaysia Police. (2025, December 9). Jenayah online: RM2.7 bilion hangus dalam 11 bulan [Online crime: RM2.7 billion lost in 11 months]. https://www.rmp.gov.my/arkib-berita/keratan-akhbar/2025/12/09/besmartstayalert-letsfightscammertogether-keratan-akhbar-pilihan-jenayah-online-rm2.7-bilion-hangus-dalam-11-bulan