diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs
index b72b90be..dc8da46d 100644
--- a/src/SMAPI.Web/BackgroundService.cs
+++ b/src/SMAPI.Web/BackgroundService.cs
@@ -278,6 +278,7 @@ internal class BackgroundService : IHostedService, IDisposable
string copyToPath = Path.Combine(BackgroundService.WebRootPath, "Content", "data", "mods-by-type.jsonl");
Directory.CreateDirectory(Path.GetDirectoryName(copyToPath)!);
File.Copy(BackgroundService.ModDatasetRepo.GetFilePath("stats/mods by type.jsonl"), copyToPath, overwrite: true);
+ File.Copy(BackgroundService.ModDatasetRepo.GetFilePath("stats/Content Patcher packs by format version.jsonl"), Path.Combine(BackgroundService.WebRootPath, "Content", "data", "content-packs-by-format.jsonl"), overwrite: true);
context.WriteLine("Done!");
}
diff --git a/src/SMAPI.Web/Views/Index/Stats.cshtml b/src/SMAPI.Web/Views/Index/Stats.cshtml
index 68827a1b..c41b3cc3 100644
--- a/src/SMAPI.Web/Views/Index/Stats.cshtml
+++ b/src/SMAPI.Web/Views/Index/Stats.cshtml
@@ -18,7 +18,8 @@
@@ -40,22 +41,27 @@
@* stats area *@
-
These stats are generated automatically from the
Stardew mod dataset , last updated on {{modsByType.lastUpdated}}.
-
+ @* header *@
+
-
+
Enable advanced controls
+ @* mods by type *@
Mods by type
-
We have {{modsByType.summary.total.toLocaleString()}} tracked mods. The majority use {{modsByType.summary.topThree[0].label}} ({{modsByType.summary.topThree[0].percent}}%), followed by {{modsByType.summary.topThree[1].label}} at {{modsByType.summary.topThree[1].percent}}% and {{modsByType.summary.topThree[2].label}} at {{modsByType.summary.topThree[2].percent}}%.
Legacy XNB mods are no longer recommended, and now only make up {{modsByType.summary.xnbPercent}}% of mods.
-
-
-
+
Based on data from {{modsByType.lastUpdated}}.
+
We have {{modsByType.total.toLocaleString()}} tracked mods. The majority use {{modsByType.topThree[0].label}} ({{modsByType.topThree[0].percent}}%), followed by {{modsByType.topThree[1].label}} at {{modsByType.topThree[1].percent}}% and {{modsByType.topThree[2].label}} at {{modsByType.topThree[2].percent}}%.
Legacy XNB mods are no longer recommended, and now only make up {{modsByType.xnbPercent}}% of mods.
+
+
+
+
+ @* new mods *@
New mods
+
Based on data from {{modsByType.lastUpdated}}.
Between {{modsByType.previouslyUpdated}} and {{modsByType.lastUpdated}}, there were {{latestNewMods.total.toLocaleString()}} more mods. That includes:
@@ -63,7 +69,21 @@
and {{formatDelta(latestNewMods.forOther)}} for frameworks with <100 content packs.
-
-
+
+
+
+ @* Content Patcher packs *@
+
Content Patcher packs
+
Based on data from {{contentPacks.lastUpdated}}.
+
+
+ We have {{contentPacks.total.toLocaleString()}} tracked
Content Patcher packs. The top five format versions are (with the change since {{contentPacks.previouslyUpdated}}):
+
+ {{i == 4 ? "and " : ""}} {{entry.version}} ({{entry.count.toLocaleString()}}, {{formatDelta(entry.delta)}}){{i < 4 ? ";" : "."}}
+
+
+
+
+
diff --git a/src/SMAPI.Web/wwwroot/Content/css/stats.css b/src/SMAPI.Web/wwwroot/Content/css/stats.css
index ea022eaa..be7b297b 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/stats.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/stats.css
@@ -26,6 +26,11 @@ div.error {
color: #555;
}
+#stats-area .last-updated {
+ font-size: 0.7em;
+ opacity: 0.7;
+}
+
#stats-area .chart-intro {
max-width: 60em;
margin-top: 1em;
@@ -38,11 +43,10 @@ div.error {
border-radius: 3px;
}
-#stats-area .chart#modsByType,
-#stats-area .chart#newModsLastDelta {
+#stats-area .pie-chart {
max-width: 900px; /* pie charts don't extend across page, so keep them left-aligned in their box */
}
-#stats-area .chart#newMods {
- height: 400px; /* bar chart doesn't need as much vertical room to be readable */
+#stats-area .bar-chart {
+ height: 400px; /* bar charts don't need as much vertical room to be readable */
}
diff --git a/src/SMAPI.Web/wwwroot/Content/js/stats.js b/src/SMAPI.Web/wwwroot/Content/js/stats.js
index 8bfdb582..da696a20 100644
--- a/src/SMAPI.Web/wwwroot/Content/js/stats.js
+++ b/src/SMAPI.Web/wwwroot/Content/js/stats.js
@@ -52,6 +52,19 @@ smapi.statsPage = function (options) {
"2024-04-26": "Stardew Valley 1.6 (19 March 2024)",
"2024-11-29": "Stardew Valley 1.6.9 (04 November 2024)"
}
+ },
+
+ // configure the 'content packs by format version' stats
+ contentPacks: {
+ // colors for each format version (omitted entries cycle through the colorPalette)
+ colors: {},
+
+ // notable events which may affect stats, indexed by their date in content-packs-by-format.jsonl
+ notableEvents: {
+ "2021-10-01": "Stardew Valley 1.5.5 (17 August 2021)",
+ "2024-04-01": "Stardew Valley 1.6 (19 March 2024)",
+ "2024-11-29": "Stardew Valley 1.6.9 (04 November 2024)"
+ }
}
};
@@ -69,6 +82,12 @@ smapi.statsPage = function (options) {
newMods: {
deltas: [],
xLabels: []
+ },
+ contentPacks: {
+ rows: [],
+ lastRow: {},
+ xLabels: [],
+ versions: []
}
};
const charts = [];
@@ -87,16 +106,20 @@ smapi.statsPage = function (options) {
modsByType: {
previouslyUpdated: null,
lastUpdated: null,
- summary: {
- total: 0,
- topThree: [],
- xnbPercent: "0"
- }
+ total: 0,
+ topThree: [],
+ xnbPercent: "0"
},
latestNewMods: {
total: 0,
byType: [],
forOther: 0
+ },
+ contentPacks: {
+ previouslyUpdated: null,
+ lastUpdated: null,
+ total: 0,
+ topVersions: []
}
},
watch: {
@@ -124,15 +147,23 @@ smapi.statsPage = function (options) {
*/
loadDataAsync: async function () {
/*********
- ** Fetch mods by type
+ ** Fetch data files
+ *********/
+ const [modsByTypeRows, contentPacksRows] = await Promise.all([
+ fetchJsonLinesAsync(options.modsByTypeUri),
+ fetchJsonLinesAsync(options.contentPacksByFormatUri)
+ ]);
+ if (!modsByTypeRows || !contentPacksRows) {
+ this.isDataFailed = true;
+ return;
+ }
+
+
+ /*********
+ ** Process mods by type
*********/
{
- // fetch dataset
- const rows = await fetchJsonLinesAsync(options.modsByTypeUri);
- if (!rows) {
- this.isDataFailed = true;
- return;
- }
+ const rows = modsByTypeRows;
// derive dataset info
const lastRow = rows[rows.length - 1];
@@ -143,7 +174,7 @@ smapi.statsPage = function (options) {
rows,
lastRow,
xLabels: rows.map(row => row.date),
- modTypes: getModTypes(rows)
+ modTypes: getModKeys(rows)
};
this.modsByType.previouslyUpdated = data.modsByType.previouslyUpdated;
this.modsByType.lastUpdated = data.modsByType.lastUpdated;
@@ -151,30 +182,23 @@ smapi.statsPage = function (options) {
// summarize mods by type
{
const curData = data.modsByType;
- const total = curData.modTypes.reduce((sum, modType) => sum + (curData.lastRow[modType] ?? 0), 0);
+ const total = getSum(curData.modTypes, modType => curData.lastRow[modType]);
const topThree = curData.modTypes
.filter(isSpecificModType)
.slice(0, 3)
.map(modType => ({ label: getLabel(config.modsByType.labels, modType), percent: getPercent(curData.lastRow[modType], total) }));
- this.modsByType.summary = {
- total,
- topThree,
- xnbPercent: getPercent(curData.lastRow["XNB"], total)
- };
+ this.modsByType.total = total;
+ this.modsByType.topThree = topThree;
+ this.modsByType.xnbPercent = getPercent(curData.lastRow["XNB"], total);
}
// assign colors for each mod type
- let index = 0;
- for (const modType of data.modsByType.modTypes) {
- if (config.modsByType.colors[modType])
- continue; // assigned in config
-
- config.modsByType.colors[modType] = config.colorPalette[index++ % config.colorPalette.length];
- }
+ assignColors(config.modsByType.colors, data.modsByType.modTypes);
}
+
/*********
** Calculate 'new mods this month' stats
*********/
@@ -186,7 +210,7 @@ smapi.statsPage = function (options) {
const excludeRow = row => excludeDates.has(row.date);
// get total new mods per month (skip first row which has no delta)
- const totals = curData.rows.map(row => curData.modTypes.reduce((curTotal, modType) => curTotal + (row[modType] ?? 0), 0));
+ const totals = curData.rows.map(row => getSum(curData.modTypes, modType => row[modType]));
const deltas = curData.rows
.slice(1)
.map((_, i) => totals[i + 1] - totals[i])
@@ -222,6 +246,40 @@ smapi.statsPage = function (options) {
}
+ /*********
+ ** Process content packs by format version
+ *********/
+ {
+ // read data
+ const rows = contentPacksRows;
+ const lastRow = rows[rows.length - 1];
+ const prevRow = rows[rows.length - 2];
+ const versions = getModKeys(rows);
+ data.contentPacks = {
+ rows,
+ lastRow,
+ xLabels: rows.map(row => row.date),
+ versions
+ };
+
+ // assign colors for each format version
+ assignColors(config.contentPacks.colors, versions);
+
+ // summarize top versions
+ const total = getSum(versions, version => lastRow[version]);
+ this.contentPacks = {
+ previouslyUpdated: formatFullDate(prevRow.date),
+ lastUpdated: formatFullDate(lastRow.date),
+ total,
+ topVersions: versions.slice(0, 5).map(version => ({
+ version,
+ count: lastRow[version] ?? 0,
+ delta: (lastRow[version] ?? 0) - (prevRow[version] ?? 0)
+ }))
+ };
+ }
+
+
/*********
** Mark data ready
*********/
@@ -255,19 +313,13 @@ smapi.statsPage = function (options) {
chart.setOption({
baseOption: {
- series: [
- {
- type: "pie",
- radius: "80%",
- clockwise: false // start with larger values on the left
- }
- ],
+ series: [createPieChartSeries()],
timeline: createTimeline(curData.xLabels, this.showAdvancedControls, config.modsByType.notableEvents),
toolbox: createToolbox(false, this.showAdvancedControls)
},
options: curData.rows.map((row, i) => {
const isLatest = i === lastRowIndex;
- const total = curData.modTypes.reduce((sum, type) => sum + (row[type] ?? 0), 0);
+ const total = getSum(curData.modTypes, modType => row[modType]);
return {
title: {
@@ -321,7 +373,7 @@ smapi.statsPage = function (options) {
{
id: "modsByTypeOverTimeExcludingOutliers",
title: "Mods by type over time (excluding SMAPI and Content Patcher)",
- modTypes: curData.modTypes.filter(k => isSpecificModType(k) && k !== "SMAPI" && k !== "Pathoschild.ContentPatcher")
+ modTypes: curData.modTypes.filter(key => isSpecificModType(key) && key !== "SMAPI" && key !== "Pathoschild.ContentPatcher")
}
];
@@ -337,10 +389,10 @@ smapi.statsPage = function (options) {
top: 40,
bottom: this.showAdvancedControls ? 100 : 60, // make room for timeline
left: 60,
- right: 150 // extra right margin to fit end labels
+ right: 150 // make room for end labels
},
xAxis: {
- data: [], // overridden in `options`
+ data: [], // overridden per-frame in `options`
axisLabel: {
rotate: 90,
fontSize: 11,
@@ -358,7 +410,7 @@ smapi.statsPage = function (options) {
return {
type: "line",
name: getLabel(config.modsByType.labels, modType),
- data: [], // overridden in `options`
+ data: [], // overridden per-frame in `options`
color: color,
lineStyle: {
type: extendsToEnd ? "solid" : "dotted",
@@ -427,13 +479,11 @@ smapi.statsPage = function (options) {
baseOption: {
series: [
{
- type: "pie",
- radius: "80%",
- clockwise: false, // start with larger values on the left
+ ...createPieChartSeries(),
label: {
formatter: entry => `${entry.name} ${entry.percent.toFixed(1)}%`
},
- data: [] // overridden in `options`
+ data: [] // overridden per-frame in `options`
}
],
tooltip: {
@@ -547,6 +597,165 @@ smapi.statsPage = function (options) {
});
charts.push(chart);
}
+
+
+ /*********
+ ** 'Content packs by format version' pie chart
+ *********/
+ {
+ const curData = data.contentPacks;
+ const lastRowIndex = curData.rows.length - 1;
+
+ const chart = echarts.init(document.getElementById("contentPacksByFormat"));
+
+ chart.setOption({
+ baseOption: {
+ series: [createPieChartSeries()],
+ timeline: createTimeline(curData.xLabels, this.showAdvancedControls, config.contentPacks.notableEvents),
+ toolbox: createToolbox(false, this.showAdvancedControls)
+ },
+ options: curData.rows.map((row, i) => {
+ const isLatest = i === lastRowIndex;
+ const total = getSum(curData.versions, version => row[version]);
+
+ return {
+ title: {
+ text: getTimelineTitle("Content packs by format version", row.date, isLatest),
+ textStyle: createTitleStyle()
+ },
+ series: [
+ {
+ label: {
+ formatter: entry => `${entry.name} ${getPercent(entry.value, total)}%`
+ },
+ data: curData.versions.map(version => ({
+ name: version,
+ value: row[version] ?? 0,
+ label: {
+ show: !!row[version]
+ },
+ itemStyle: {
+ color: getColor(config.contentPacks.colors, version)
+ }
+ }))
+ }
+ ],
+ tooltip: {
+ formatter: entry => entry.name && entry.value
+ ? `${entry.name}: ${entry.value.toLocaleString()} (${getPercent(entry.value, total)}%)`
+ : null
+ },
+ animation: isLatest
+ };
+ })
+ });
+
+ charts.push(chart);
+ }
+
+
+ /*********
+ ** 'Content packs by format version' line chart
+ *********/
+ {
+ // init main data
+ const curData = data.contentPacks;
+ const lastRowIndex = curData.rows.length - 1;
+
+ // find versions that were ever in the top five
+ const showVersionsSet = new Set();
+ for (const row of curData.rows) {
+ for (const version of [...curData.versions].sort((a, b) => (row[b] ?? 0) - (row[a] ?? 0)).slice(0, 5))
+ showVersionsSet.add(version);
+ }
+ const showVersions = Array.from(showVersionsSet);
+
+ const chart = echarts.init(document.getElementById("contentPacksByFormatOverTime"));
+
+ chart.setOption({
+ baseOption: {
+ legend: {
+ show: false
+ },
+ grid: {
+ top: 40,
+ bottom: this.showAdvancedControls ? 100 : 60, // make room for timeline
+ left: 60,
+ right: 150 // make room for end labels
+ },
+ xAxis: {
+ data: [], // overridden per-frame in `options`
+ axisLabel: {
+ rotate: 90,
+ fontSize: 11,
+ formatter: value => value.slice(0, 7)
+ }
+ },
+ yAxis: {},
+ series: showVersions.map(version => {
+ return {
+ type: "line",
+ name: version,
+ data: [], // overridden per-frame in `options`
+ symbol: "none",
+ endLabel: {
+ formatter: "{a}" // {a} = name
+ },
+ labelLayout: {
+ moveOverlap: "shiftY"
+ }
+ };
+ }),
+ tooltip: {
+ trigger: "axis"
+ },
+ toolbox: createToolbox(true, this.showAdvancedControls),
+ timeline: createTimeline(curData.xLabels, this.showAdvancedControls, config.contentPacks.notableEvents)
+ },
+ options: curData.rows.map((row, i) => {
+ const isLatest = i === lastRowIndex;
+
+ return {
+ title: {
+ text: getTimelineTitle("Content packs by format version (top five)", row.date, isLatest),
+ textStyle: createTitleStyle()
+ },
+ xAxis: {
+ data: curData.xLabels.slice(0, i + 1)
+ },
+ series: (() => {
+ const topVersions = new Set(
+ [...showVersions]
+ .sort((a, b) => (curData.rows[i][b] ?? 0) - (curData.rows[i][a] ?? 0))
+ .slice(0, 5)
+ );
+
+ return showVersions.map(version => {
+ const highlight = topVersions.has(version);
+ const rawColor = getColor(config.contentPacks.colors, version);
+ const color = highlight ? rawColor : hexToRgba(rawColor, 0.5);
+ const seriesData = curData.rows.slice(0, i + 1).map(r => r[version] ?? null);
+
+ return {
+ data: seriesData,
+ color,
+ lineStyle: {
+ type: highlight ? "solid" : "dotted"
+ },
+ endLabel: {
+ show: !!seriesData[seriesData.length - 1],
+ color
+ }
+ };
+ });
+ })(),
+ animation: isLatest
+ };
+ })
+ });
+
+ charts.push(chart);
+ }
},
/**
@@ -636,6 +845,16 @@ smapi.statsPage = function (options) {
return (Math.round(value / total * 1000) / 10).toString();
}
+ /**
+ * Get the sum of a function over all values.
+ * @param {array