Show code
import pandas as pd
import numpy as np
import plotly.graph_objects as go
import plotly.express as px
from plotly.subplots import make_subplotschokotto
March 16, 2026
This week’s MakeoverMonday explores Mario game sales across four decades of Nintendo history. Rather than a simple ranking, we treat the franchise like an asset portfolio: examining hit concentration, platform lifecycle, and regional market share to understand how Nintendo’s flagship IP compounds value over time.
url = "https://raw.githubusercontent.com/cajjster/data_files/main/vgsales.csv"
vgsales = pd.read_csv(url)
vgsales.columns = vgsales.columns.str.replace(" ", "_")
mario = vgsales[
vgsales["Name"].str.contains("Mario", case=False, na=False)
& (vgsales["Publisher"] == "Nintendo")
].copy()
mario["Year"] = pd.to_numeric(mario["Year"], errors="coerce")
mario = mario.dropna(subset=["Year"])
mario["Year"] = mario["Year"].astype(int)
print(f"Mario titles: {mario['Name'].nunique()}")
print(f"Total records: {len(mario)}")
print(f"Year range: {mario['Year'].min()} - {mario['Year'].max()}")
print(f"Global sales: {mario['Global_Sales'].sum():.1f}M units")Mario titles: 93
Total records: 104
Year range: 1983 - 2016
Global sales: 527.3M units
The original chart has been redesigned with a “conclusion first” hierarchy:
Key design decisions: insight-driven titles, grey + single highlight colour, direct labels instead of legends.
title_by_global = (
mario.groupby("Name", as_index=False)["Global_Sales"]
.sum()
)
top10 = (
title_by_global
.nlargest(10, "Global_Sales")
.sort_values("Global_Sales")
.reset_index(drop=True)
)
total_sales = title_by_global["Global_Sales"].sum()
top5_sales = title_by_global.nlargest(5, "Global_Sales")["Global_Sales"].sum()
top5_pct = top5_sales / total_sales * 100
colors = [
"#e63946" if i >= len(top10) - 3 else "#94a3b8"
for i in range(len(top10))
]
fig = go.Figure()
fig.add_trace(
go.Bar(
y=top10["Name"],
x=top10["Global_Sales"],
orientation="h",
marker_color=colors,
text=top10["Global_Sales"].round(1),
texttemplate="%{text}M",
textposition="outside",
cliponaxis=False,
hovertemplate="%{y}<br>%{x:.1f}M units<extra></extra>",
)
)
fig.update_layout(
**THEME,
title=make_title(
f"Mario's blockbusters dominate: "
f"top 5 titles = <b>{top5_pct:.0f}%</b> of franchise sales"
),
xaxis=dict(title="Sales (M units)", showgrid=True, gridcolor="#e2e8f0", zeroline=False),
yaxis=dict(title=""),
showlegend=False,
height=500,
margin=dict(t=100, b=140, l=220, r=100),
)
fig.add_annotation(
text="Top 10 titles by global sales (M units) · 1983–2016 · <span style='color:#e63946'>■</span> Top 3",
xref="paper", yref="paper",
x=0, y=1.06,
showarrow=False,
font=dict(size=11, color="#64748b"),
align="left",
)
add_source(fig, y=-0.22)
assert_no_title_overlap(fig)
fig.show()
fig.write_image("chart-1.png", width=1200, height=600, scale=2)# --- データ準備 ---
total = mario[["NA_Sales","EU_Sales","JP_Sales","Other_Sales"]].sum()
regions = pd.DataFrame({
"Region": ["North America", "Europe", "Japan", "Other"],
"Sales": [total["NA_Sales"], total["EU_Sales"], total["JP_Sales"], total["Other_Sales"]],
"Color": ["#3b82f6", "#10b981", "#f59e0b", "#94a3b8"],
})
regions["Pct"] = regions["Sales"] / regions["Sales"].sum() * 100
na_pct = regions.loc[regions["Region"] == "North America", "Pct"].values[0]
fig_b = go.Figure()
for _, row in regions.iterrows():
pct = row["Pct"]
label_text = f"{row['Region']}<br>{pct:.0f}%" if pct >= 8 else ""
fig_b.add_trace(
go.Bar(
x=[pct], y=[""],
orientation="h",
marker_color=row["Color"],
name=row["Region"],
text=[label_text],
textposition="inside",
insidetextanchor="middle",
textfont=dict(size=12, color="white"),
hovertemplate=f"{row['Region']}: {pct:.1f}%<extra></extra>",
)
)
fig_b.update_layout(
**THEME,
title=make_title(
f"North America drives ~<b>{na_pct:.0f}%</b> of global Mario revenue"
),
barmode="stack",
showlegend=False,
height=220,
margin=dict(t=60, b=80, l=40, r=40),
xaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
yaxis=dict(showticklabels=False),
)
add_source(fig_b, y=-0.35)
assert_no_title_overlap(fig_b)
fig_b.show()genre_sales = (
mario.groupby("Genre", as_index=False)["Global_Sales"]
.sum()
.sort_values("Global_Sales", ascending=True)
.reset_index(drop=True)
)
top_genre = genre_sales.iloc[-1]["Genre"]
genre_colors = [
"#e63946" if g == top_genre else "#94a3b8"
for g in genre_sales["Genre"]
]
fig_c = go.Figure()
fig_c.add_trace(
go.Bar(
x=genre_sales["Global_Sales"],
y=genre_sales["Genre"],
orientation="h",
marker_color=genre_colors,
text=genre_sales["Global_Sales"].round(1),
texttemplate="%{text}M",
textposition="outside",
cliponaxis=False,
hovertemplate="%{y}: %{x:.1f}M<extra></extra>",
showlegend=False,
)
)
fig_c.update_layout(
**THEME,
title=make_title(
"Platform games lead, but racing & party titles diversify the portfolio"
),
showlegend=False,
height=420,
margin=dict(t=60, b=100, l=100, r=100),
xaxis=dict(
title="Sales (M units)",
showgrid=True, gridcolor="#e2e8f0", zeroline=False,
),
yaxis=dict(title=""),
)
add_source(fig_c, y=-0.22)
assert_no_title_overlap(fig_c)
fig_c.show()platform_year = (
mario.groupby(["Year", "Platform"], as_index=False)["Global_Sales"]
.sum()
)
top_platforms = (
mario.groupby("Platform")["Global_Sales"]
.sum()
.nlargest(5)
.index.tolist()
)
platform_year = (
platform_year[platform_year["Platform"].isin(top_platforms)]
.sort_values("Year")
)
PLATFORM_COLORS = {
"NES": "#e63946",
"SNES": "#f59e0b",
"N64": "#10b981",
"GBA": "#3b82f6",
"DS": "#8b5cf6",
"Wii": "#06b6d4",
"3DS": "#84cc16",
}
fig = go.Figure()
for plat in top_platforms:
df_p = platform_year[platform_year["Platform"] == plat]
color = PLATFORM_COLORS.get(plat, "#94a3b8")
fig.add_trace(
go.Scatter(
x=df_p["Year"],
y=df_p["Global_Sales"],
mode="lines+markers",
name=plat,
line=dict(color=color, width=2),
marker=dict(size=6),
hovertemplate=f"{plat} %{{x}}: %{{y:.1f}}M<extra></extra>",
)
)
# 最終年の右端にラベル
last = df_p.iloc[-1]
fig.add_annotation(
x=last["Year"], y=last["Global_Sales"],
text=f" {plat}",
showarrow=False,
xanchor="left",
font=dict(size=11, color=color),
)
fig.update_layout(
**THEME,
title=make_title("Each new Nintendo platform resets the Mario sales cycle"),
xaxis=dict(title="", showgrid=True, gridcolor="#e2e8f0", zeroline=False, dtick=4),
yaxis=dict(title="Sales (M units)", showgrid=True, gridcolor="#e2e8f0", zeroline=False),
showlegend=False,
height=420,
margin=dict(t=100, b=140, l=80, r=100),
)
fig.add_annotation(
text="Annual sales per platform · top 5 platforms by total sales · <span style='color:#e63946'>■</span> NES",
xref="paper", yref="paper",
x=0, y=1.06,
showarrow=False,
font=dict(size=11, color="#64748b"),
align="left",
)
add_source(fig, y=-0.30)
assert_no_title_overlap(fig)
fig.show()This post is part of the MakeoverMonday weekly data visualization project.
This analysis is for educational and practice purposes only. Data visualizations and interpretations are based on the provided dataset and may not represent complete or current information. Sales figures are historical and sourced from VGChartz estimates.
---
title: "MakeoverMonday: Mario Game Sales - Nintendo's Compounding Franchise Engine"
description: "Franchise lifecycle, platform transitions, and hit concentration across 40 years of Mario titles"
date: "2026-03-16"
x-posted: false
author: "chokotto"
categories:
- MakeoverMonday
- Python
- Gaming
- Business
image: "thumbnail.svg"
code-fold: true
code-tools: true
code-summary: "Show code"
twitter-card:
card-type: summary_large_image
image: "thumbnail.png"
title: "MakeoverMonday: Mario Game Sales"
description: "Franchise lifecycle, platform transitions, and hit concentration across 40 years of Mario"
---
<!-- dataviz-lessons チェック: tasks/dataviz-lessons.md を確認し過去の教訓を適用すること -->
## Overview
This week's MakeoverMonday explores **Mario game sales** across four decades of Nintendo history. Rather than a simple ranking, we treat the franchise like an asset portfolio: examining **hit concentration**, **platform lifecycle**, and **regional market share** to understand how Nintendo's flagship IP compounds value over time.
- **Data Source**: [VGChartz / vgsales dataset](https://raw.githubusercontent.com/cajjster/data_files/main/vgsales.csv)
- **Scope**: All Mario-branded Nintendo titles in the dataset (1983–2016)
- **Angle**: franchise durability, platform transitions, regional decomposition
## Data
```{python}
#| label: load-packages
#| message: false
import pandas as pd
import numpy as np
import plotly.graph_objects as go
import plotly.express as px
from plotly.subplots import make_subplots
```
```{python}
#| label: load-data
#| message: false
url = "https://raw.githubusercontent.com/cajjster/data_files/main/vgsales.csv"
vgsales = pd.read_csv(url)
vgsales.columns = vgsales.columns.str.replace(" ", "_")
mario = vgsales[
vgsales["Name"].str.contains("Mario", case=False, na=False)
& (vgsales["Publisher"] == "Nintendo")
].copy()
mario["Year"] = pd.to_numeric(mario["Year"], errors="coerce")
mario = mario.dropna(subset=["Year"])
mario["Year"] = mario["Year"].astype(int)
print(f"Mario titles: {mario['Name'].nunique()}")
print(f"Total records: {len(mario)}")
print(f"Year range: {mario['Year'].min()} - {mario['Year'].max()}")
print(f"Global sales: {mario['Global_Sales'].sum():.1f}M units")
```
```{python}
#| label: shared-style
#| include: false
NOTE_TEXT = "Note: Mario-branded Nintendo titles only"
SOURCE_TEXT = "Source: VGChartz / vgsales dataset | © 2026 chokotto"
# Note が 50 文字以下なら 1 行、超えたら Source 以降を 2 行目に分ける
_NOTE_THRESHOLD = 50
def _build_source_note(note=NOTE_TEXT, source=SOURCE_TEXT):
if len(note) <= _NOTE_THRESHOLD:
return f"{note} | {source}"
return f"{note}<br>{source}"
SOURCE_NOTE = _build_source_note()
THEME = dict(
template="plotly_white",
font=dict(family="sans-serif", size=13, color="#1e293b"),
paper_bgcolor="white",
plot_bgcolor="#f8fafc",
)
def make_title(text):
return dict(text=text, font=dict(size=15, color="#1e293b"), x=0, xanchor="left")
def add_source(fig, y=-0.20):
fig.add_annotation(
text=SOURCE_NOTE,
xref="paper", yref="paper",
x=0, y=y,
showarrow=False,
font=dict(size=10, color="#94a3b8", style="italic"),
align="left",
yanchor="top",
)
def assert_no_title_overlap(fig):
"""タイトル・annotation がプロット領域上端と重なっていないかを検証し、
重なりを検出した場合は margin_t を自動補正する(出力なし)。"""
layout = fig.layout
height = layout.height or 450
margin_t = layout.margin.t if layout.margin.t is not None else 80
plot_top = 1.0 - margin_t / height
for ann in fig.layout.annotations:
if ann.yref == "paper" and ann.y is not None and ann.y > plot_top:
required_t = int((1.0 - ann.y + 0.06) * height) + 30
fig.update_layout(margin=dict(t=required_t))
margin_t = required_t
plot_top = 1.0 - margin_t / height
title_y = None
if fig.layout.title and fig.layout.title.y is not None:
title_y = fig.layout.title.y
elif fig.layout.title and fig.layout.title.text:
title_y = 0.98
if title_y is not None and title_y > plot_top:
required_t = int((1.0 - title_y + 0.06) * height) + 30
fig.update_layout(margin=dict(t=required_t))
```
## My Makeover
### What I Changed
The original chart has been redesigned with a **"conclusion first" hierarchy**:
1. **Hero — Hit Concentration**: one chart that immediately shows where the money is
2. **Decomposition**: regional mix and genre diversity side by side
3. **Context**: how platform cycles drive the sales pattern over time
Key design decisions: insight-driven titles, grey + single highlight colour, direct labels instead of legends.
### Hit Concentration: A Few Blockbusters Carry the Franchise
```{python}
#| label: hero-top10
#| fig-width: 10
#| fig-height: 6
title_by_global = (
mario.groupby("Name", as_index=False)["Global_Sales"]
.sum()
)
top10 = (
title_by_global
.nlargest(10, "Global_Sales")
.sort_values("Global_Sales")
.reset_index(drop=True)
)
total_sales = title_by_global["Global_Sales"].sum()
top5_sales = title_by_global.nlargest(5, "Global_Sales")["Global_Sales"].sum()
top5_pct = top5_sales / total_sales * 100
colors = [
"#e63946" if i >= len(top10) - 3 else "#94a3b8"
for i in range(len(top10))
]
fig = go.Figure()
fig.add_trace(
go.Bar(
y=top10["Name"],
x=top10["Global_Sales"],
orientation="h",
marker_color=colors,
text=top10["Global_Sales"].round(1),
texttemplate="%{text}M",
textposition="outside",
cliponaxis=False,
hovertemplate="%{y}<br>%{x:.1f}M units<extra></extra>",
)
)
fig.update_layout(
**THEME,
title=make_title(
f"Mario's blockbusters dominate: "
f"top 5 titles = <b>{top5_pct:.0f}%</b> of franchise sales"
),
xaxis=dict(title="Sales (M units)", showgrid=True, gridcolor="#e2e8f0", zeroline=False),
yaxis=dict(title=""),
showlegend=False,
height=500,
margin=dict(t=100, b=140, l=220, r=100),
)
fig.add_annotation(
text="Top 10 titles by global sales (M units) · 1983–2016 · <span style='color:#e63946'>■</span> Top 3",
xref="paper", yref="paper",
x=0, y=1.06,
showarrow=False,
font=dict(size=11, color="#64748b"),
align="left",
)
add_source(fig, y=-0.22)
assert_no_title_overlap(fig)
fig.show()
fig.write_image("chart-1.png", width=1200, height=600, scale=2)
```
### Regional Mix: North America Leads
```{python}
#| label: regional-mix
#| fig-width: 10
#| fig-height: 2.5
# --- データ準備 ---
total = mario[["NA_Sales","EU_Sales","JP_Sales","Other_Sales"]].sum()
regions = pd.DataFrame({
"Region": ["North America", "Europe", "Japan", "Other"],
"Sales": [total["NA_Sales"], total["EU_Sales"], total["JP_Sales"], total["Other_Sales"]],
"Color": ["#3b82f6", "#10b981", "#f59e0b", "#94a3b8"],
})
regions["Pct"] = regions["Sales"] / regions["Sales"].sum() * 100
na_pct = regions.loc[regions["Region"] == "North America", "Pct"].values[0]
fig_b = go.Figure()
for _, row in regions.iterrows():
pct = row["Pct"]
label_text = f"{row['Region']}<br>{pct:.0f}%" if pct >= 8 else ""
fig_b.add_trace(
go.Bar(
x=[pct], y=[""],
orientation="h",
marker_color=row["Color"],
name=row["Region"],
text=[label_text],
textposition="inside",
insidetextanchor="middle",
textfont=dict(size=12, color="white"),
hovertemplate=f"{row['Region']}: {pct:.1f}%<extra></extra>",
)
)
fig_b.update_layout(
**THEME,
title=make_title(
f"North America drives ~<b>{na_pct:.0f}%</b> of global Mario revenue"
),
barmode="stack",
showlegend=False,
height=220,
margin=dict(t=60, b=80, l=40, r=40),
xaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
yaxis=dict(showticklabels=False),
)
add_source(fig_b, y=-0.35)
assert_no_title_overlap(fig_b)
fig_b.show()
```
### Genre Breakdown: Platform Dominates
```{python}
#| label: genre-breakdown
#| fig-width: 10
#| fig-height: 5
genre_sales = (
mario.groupby("Genre", as_index=False)["Global_Sales"]
.sum()
.sort_values("Global_Sales", ascending=True)
.reset_index(drop=True)
)
top_genre = genre_sales.iloc[-1]["Genre"]
genre_colors = [
"#e63946" if g == top_genre else "#94a3b8"
for g in genre_sales["Genre"]
]
fig_c = go.Figure()
fig_c.add_trace(
go.Bar(
x=genre_sales["Global_Sales"],
y=genre_sales["Genre"],
orientation="h",
marker_color=genre_colors,
text=genre_sales["Global_Sales"].round(1),
texttemplate="%{text}M",
textposition="outside",
cliponaxis=False,
hovertemplate="%{y}: %{x:.1f}M<extra></extra>",
showlegend=False,
)
)
fig_c.update_layout(
**THEME,
title=make_title(
"Platform games lead, but racing & party titles diversify the portfolio"
),
showlegend=False,
height=420,
margin=dict(t=60, b=100, l=100, r=100),
xaxis=dict(
title="Sales (M units)",
showgrid=True, gridcolor="#e2e8f0", zeroline=False,
),
yaxis=dict(title=""),
)
add_source(fig_c, y=-0.22)
assert_no_title_overlap(fig_c)
fig_c.show()
```
### Annual Sales by Platform Era
```{python}
#| label: platform-era
#| fig-width: 10
#| fig-height: 5
platform_year = (
mario.groupby(["Year", "Platform"], as_index=False)["Global_Sales"]
.sum()
)
top_platforms = (
mario.groupby("Platform")["Global_Sales"]
.sum()
.nlargest(5)
.index.tolist()
)
platform_year = (
platform_year[platform_year["Platform"].isin(top_platforms)]
.sort_values("Year")
)
PLATFORM_COLORS = {
"NES": "#e63946",
"SNES": "#f59e0b",
"N64": "#10b981",
"GBA": "#3b82f6",
"DS": "#8b5cf6",
"Wii": "#06b6d4",
"3DS": "#84cc16",
}
fig = go.Figure()
for plat in top_platforms:
df_p = platform_year[platform_year["Platform"] == plat]
color = PLATFORM_COLORS.get(plat, "#94a3b8")
fig.add_trace(
go.Scatter(
x=df_p["Year"],
y=df_p["Global_Sales"],
mode="lines+markers",
name=plat,
line=dict(color=color, width=2),
marker=dict(size=6),
hovertemplate=f"{plat} %{{x}}: %{{y:.1f}}M<extra></extra>",
)
)
# 最終年の右端にラベル
last = df_p.iloc[-1]
fig.add_annotation(
x=last["Year"], y=last["Global_Sales"],
text=f" {plat}",
showarrow=False,
xanchor="left",
font=dict(size=11, color=color),
)
fig.update_layout(
**THEME,
title=make_title("Each new Nintendo platform resets the Mario sales cycle"),
xaxis=dict(title="", showgrid=True, gridcolor="#e2e8f0", zeroline=False, dtick=4),
yaxis=dict(title="Sales (M units)", showgrid=True, gridcolor="#e2e8f0", zeroline=False),
showlegend=False,
height=420,
margin=dict(t=100, b=140, l=80, r=100),
)
fig.add_annotation(
text="Annual sales per platform · top 5 platforms by total sales · <span style='color:#e63946'>■</span> NES",
xref="paper", yref="paper",
x=0, y=1.06,
showarrow=False,
font=dict(size=11, color="#64748b"),
align="left",
)
add_source(fig, y=-0.30)
assert_no_title_overlap(fig)
fig.show()
```
## Key Takeaways
1. **Hit concentration is extreme**: The top 5 titles account for roughly two-thirds of total franchise sales, mirroring power-law distributions seen in financial assets
2. **Regional dominance**: North America drives the majority of revenue, but Japan and Europe provide meaningful diversification
3. **Genre portfolio effect**: Platform games lead in volume, while Mario Kart and party titles create recurring revenue across generations
4. **Platform cycle reset**: Each new Nintendo hardware generation produces a fresh sales spike, with the Wii and DS era being the largest
***
_This post is part of the [MakeoverMonday](https://www.makeovermonday.co.uk/) weekly data visualization project._
:::{.callout-caution collapse="false" appearance="minimal" icon="false"}
## Disclaimer
::: {style="font-size: 0.85em; color: #64748b; line-height: 1.6;"}
This analysis is for educational and practice purposes only. Data visualizations and interpretations are based on the provided dataset and may not represent complete or current information. Sales figures are historical and sourced from VGChartz estimates.
:::
:::