mirror of
https://github.com/Pathoschild/SMAPI.git
synced 2026-10-12 02:08:51 +00:00
add web costs & Patreon to stats page
This commit is contained in:
@@ -279,6 +279,7 @@ internal class BackgroundService : IHostedService, IDisposable
|
||||
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);
|
||||
File.Copy(BackgroundService.ModDatasetRepo.GetFilePath("reference-data/SMAPI costs.jsonl"), Path.Combine(BackgroundService.WebRootPath, "Content", "data", "smapi-costs.jsonl"), overwrite: true);
|
||||
|
||||
context.WriteLine("Done!");
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
$(function () {
|
||||
smapi.statsPage({
|
||||
modsByTypeUri: @this.ForJson(Url.ContentWithCacheBust("~/Content/data/mods-by-type.jsonl")),
|
||||
contentPacksByFormatUri: @this.ForJson(Url.ContentWithCacheBust("~/Content/data/content-packs-by-format.jsonl"))
|
||||
contentPacksByFormatUri: @this.ForJson(Url.ContentWithCacheBust("~/Content/data/content-packs-by-format.jsonl")),
|
||||
smapiCostsUri: @this.ForJson(Url.ContentWithCacheBust("~/Content/data/smapi-costs.jsonl"))
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -34,6 +35,7 @@
|
||||
<li><a href="#mods-by-type">Mods by type</a></li>
|
||||
<li><a href="#new-mods">New mods</a></li>
|
||||
<li><a href="#content-patcher-packs">Content Patcher packs</a></li>
|
||||
<li><a href="#smapi-costs">SMAPI costs</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
@@ -58,7 +60,7 @@
|
||||
<div id="stats-intro">These stats are generated automatically from the <a href="https://github.com/Pathoschild/StardewModData">Stardew mod dataset</a>.</div>
|
||||
<div id="stats-controls">
|
||||
<label>
|
||||
<input type="checkbox" v-model="showAdvancedControls"/>
|
||||
<input type="checkbox" v-model="showAdvancedControls" />
|
||||
Enable advanced controls
|
||||
</label>
|
||||
</div>
|
||||
@@ -98,5 +100,33 @@
|
||||
|
||||
<div class="chart pie-chart" id="contentPacksByFormat"></div>
|
||||
<div class="chart line-chart" id="contentPacksByFormatOverTime"></div>
|
||||
|
||||
@* SMAPI costs *@
|
||||
<h2 id="smapi-costs">SMAPI costs</h2>
|
||||
<div class="last-updated">Based on data from {{costs.lastUpdated}}. This information is delayed due to billing cycles.</div>
|
||||
|
||||
<div class="chart-intro">
|
||||
<p>
|
||||
SMAPI has monthly costs for the domains, update alert servers, web hosting for pages like this, etc. Those monthly costs
|
||||
<template v-if="costs.percentChange <= 0.05">held steady around US${{costs.latestCost.toFixed(0)}}/month</template>
|
||||
<template v-else-if="costs.percentChange <= 0.10">{{costs.latestCost > costs.previousCost ? "rose" : "dropped"}} a bit from US${{costs.previousCost.toFixed(0)}} to ${{costs.latestCost.toFixed(0)}}/month</template>
|
||||
<template v-else>{{costs.latestCost > costs.previousCost ? "rose" : "dropped"}} from US${{costs.previousCost.toFixed(0)}} to ${{costs.latestCost.toFixed(0)}}/month</template>
|
||||
in {{costs.lastUpdated}}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="chart" id="costsOverTime"></div>
|
||||
|
||||
<div id="support-box">
|
||||
<p>Tip: you can help cover SMAPI costs by joining <a href="https://www.patreon.com/pathoschild">its author's Patreon</a> (starting from $1/month)!</p>
|
||||
|
||||
<p>That comes with monthly posts about:</p>
|
||||
<ul>
|
||||
<li>what's happening in the Stardew Valley modding community;</li>
|
||||
<li>analyses of changes to these stats;</li>
|
||||
<li>sneak peeks at modding changes in upcoming versions of the game;</li>
|
||||
<li>discussion about proposed modding changes;</li>
|
||||
<li>and more.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,3 +50,10 @@ div.error {
|
||||
#stats-area .bar-chart {
|
||||
height: 400px; /* bar charts don't need as much vertical room to be readable */
|
||||
}
|
||||
|
||||
#stats-area #support-box {
|
||||
max-width: 45em;
|
||||
background: rgba(0, 170, 0, 0.2);
|
||||
padding: 0.5em 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,19 @@ smapi.statsPage = function (options) {
|
||||
"2024-04-01": "Stardew Valley 1.6 (19 March 2024)",
|
||||
"2024-11-29": "Stardew Valley 1.6.9 (04 November 2024)"
|
||||
}
|
||||
},
|
||||
|
||||
// configure the 'SMAPI costs' stats
|
||||
costs: {
|
||||
// colors for each service (omitted entries cycle through the colorPalette)
|
||||
colors: {
|
||||
"GitHub": "#ddcc77", // gold
|
||||
"MongoDB": "#aa4499", // magenta
|
||||
"Azure hosting": "#db4437", // muted rose
|
||||
"Amazon hosting": "#332288", // dark indigo
|
||||
"Amazon SSL": "#44aa99", // turquoise
|
||||
"Amazon domains": "#117733" // dark green
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,6 +101,11 @@ smapi.statsPage = function (options) {
|
||||
lastRow: {},
|
||||
xLabels: [],
|
||||
versions: []
|
||||
},
|
||||
costs: {
|
||||
rows: [],
|
||||
serviceKeys: [],
|
||||
xLabels: []
|
||||
}
|
||||
};
|
||||
const charts = [];
|
||||
@@ -120,6 +138,12 @@ smapi.statsPage = function (options) {
|
||||
lastUpdated: null,
|
||||
total: 0,
|
||||
topVersions: []
|
||||
},
|
||||
costs: {
|
||||
lastUpdated: null,
|
||||
previousCost: 0,
|
||||
latestCost: 0,
|
||||
percentChange: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -155,11 +179,12 @@ smapi.statsPage = function (options) {
|
||||
/*********
|
||||
** Fetch data files
|
||||
*********/
|
||||
const [modsByTypeRows, contentPacksRows] = await Promise.all([
|
||||
const [modsByTypeRows, contentPacksRows, costsRows] = await Promise.all([
|
||||
fetchJsonLinesAsync(options.modsByTypeUri),
|
||||
fetchJsonLinesAsync(options.contentPacksByFormatUri)
|
||||
fetchJsonLinesAsync(options.contentPacksByFormatUri),
|
||||
fetchJsonLinesAsync(options.smapiCostsUri)
|
||||
]);
|
||||
if (!modsByTypeRows || !contentPacksRows) {
|
||||
if (!modsByTypeRows || !contentPacksRows || !costsRows) {
|
||||
this.isDataFailed = true;
|
||||
return;
|
||||
}
|
||||
@@ -286,6 +311,60 @@ smapi.statsPage = function (options) {
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Process SMAPI costs
|
||||
*********/
|
||||
{
|
||||
// read data
|
||||
const rows = [...costsRows]; // copy since we'll be mutating it
|
||||
const serviceKeys = [...new Set(rows.flatMap(row => Object.keys(row).filter(isDataField)))];
|
||||
const xLabels = rows.map(row => row.date);
|
||||
|
||||
// amortize annual costs into monthly ones (so graph is more representative of average monthly costs)
|
||||
{
|
||||
const amortizing = [];
|
||||
for (const row of rows) {
|
||||
// collect new amortized amounts
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (value.amount && value.months) {
|
||||
amortizing.push({ key, perMonth: value.amount / value.months, months: value.months });
|
||||
row[key] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// apply amortized amounts
|
||||
for (let i = amortizing.length - 1; i >= 0; i--) {
|
||||
const entry = amortizing[i];
|
||||
row[entry.key] = (row[entry.key] ?? 0) + entry.perMonth;
|
||||
entry.months--;
|
||||
|
||||
if (entry.months < 1)
|
||||
amortizing.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sort by descending cost in the last row (so the largest segment is at the bottom of the stacked bar)
|
||||
serviceKeys.sort((a, b) => (rows[rows.length - 1][b] ?? 0) - (rows[rows.length - 1][a] ?? 0));
|
||||
|
||||
// set data
|
||||
assignColors(config.costs.colors, serviceKeys);
|
||||
data.costs = { rows, serviceKeys, xLabels };
|
||||
|
||||
// set summary
|
||||
const prevRow = rows[rows.length - 2];
|
||||
const lastRow = rows[rows.length - 1];
|
||||
const previousCost = Math.round(getSum(serviceKeys, key => prevRow[key]) * 100) / 100;
|
||||
const latestCost = Math.round(getSum(serviceKeys, key => lastRow[key]) * 100) / 100;
|
||||
this.costs = {
|
||||
lastUpdated: formatMonthYear(rows[rows.length - 1].date),
|
||||
previousCost,
|
||||
latestCost,
|
||||
percentChange: Math.abs((latestCost - previousCost) / previousCost)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Mark data ready
|
||||
*********/
|
||||
@@ -762,6 +841,70 @@ smapi.statsPage = function (options) {
|
||||
|
||||
charts.push(chart);
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** 'SMAPI costs over time' stacked bar chart
|
||||
*********/
|
||||
{
|
||||
const curData = data.costs;
|
||||
|
||||
const chart = echarts.init(document.getElementById("costsOverTime"));
|
||||
|
||||
chart.setOption({
|
||||
title: {
|
||||
text: `SMAPI costs (${curData.xLabels[0]} through ${curData.xLabels[curData.xLabels.length - 1]})`,
|
||||
textStyle: createTitleStyle()
|
||||
},
|
||||
legend: {
|
||||
top: 25
|
||||
},
|
||||
grid: {
|
||||
top: 65,
|
||||
bottom: this.showAdvancedControls ? 80 : 60,
|
||||
left: 60,
|
||||
right: 20
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: curData.xLabels,
|
||||
axisLabel: {
|
||||
rotate: 90,
|
||||
fontSize: 11
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "USD"
|
||||
},
|
||||
series: curData.serviceKeys.map(key => ({
|
||||
type: "bar",
|
||||
name: key,
|
||||
stack: "total",
|
||||
data: curData.rows.map(row => Math.round((row[key] ?? 0) * 100) / 100),
|
||||
color: getColor(config.costs.colors, key)
|
||||
})),
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
formatter: params => {
|
||||
const total = getSum(params, p => p.value);
|
||||
|
||||
const lines = [
|
||||
params[0].axisValue, // date
|
||||
...params
|
||||
.filter(p => p.value > 0)
|
||||
.map(p => `${p.marker}${p.seriesName}: $${p.value.toFixed(2)}`),
|
||||
`<strong>Total: US$${total.toFixed(2)}</strong>`
|
||||
];
|
||||
|
||||
return lines.join("<br />");
|
||||
}
|
||||
},
|
||||
toolbox: createToolbox(true, this.showAdvancedControls)
|
||||
});
|
||||
|
||||
charts.push(chart);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -818,6 +961,14 @@ smapi.statsPage = function (options) {
|
||||
return formatDate(date, { dateStyle: "long" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a formatted month string in the form 'Month YYYY', like 'January 2030'.
|
||||
* @param {string} monthDateStr The month date string in the form 'YYYY-MM'.
|
||||
*/
|
||||
function formatMonthYear(monthDateStr) {
|
||||
return formatDate(monthDateStr + "-01", { month: "long", year: "numeric" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a data key.
|
||||
* @param {object} lookup The name dictionary to check.
|
||||
@@ -880,7 +1031,7 @@ smapi.statsPage = function (options) {
|
||||
* @returns {array<string>}
|
||||
*/
|
||||
function getModKeys(rows) {
|
||||
const rawModTypes = rows.flatMap(row => Object.keys(row).filter(isModType));
|
||||
const rawModTypes = rows.flatMap(row => Object.keys(row).filter(isDataField));
|
||||
const uniqueModTypes = [...new Set(rawModTypes)];
|
||||
|
||||
const lastRow = rows[rows.length - 1];
|
||||
@@ -911,23 +1062,34 @@ smapi.statsPage = function (options) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether a field name in a dated row is metadata (like a date or comment) rather than one of the actual data fields.
|
||||
* @param {string} key The data key to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isMetaField(key) {
|
||||
return key === "date" || key === "@comment";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether a field name in a dated row is a data value, instead of a metadata field (like a date or comment).
|
||||
* @param {string} key The data key to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDataField(key) {
|
||||
return !isMetaField(key);
|
||||
}
|
||||
|
||||
/****
|
||||
** Mods-by-type data
|
||||
****/
|
||||
/**
|
||||
* Get whether a key in a data row is a mod type, rather than a metadata field (like 'date' or '@comment').
|
||||
* @param {string} key The data key to check.
|
||||
*/
|
||||
function isModType(key) {
|
||||
return key !== "date" && key !== "@comment";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether a key in a data row is a strict mod type, rather than a metadata field (like 'date' or '@comment') or 'other'.
|
||||
* @param {string} key The data key to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSpecificModType(key) {
|
||||
return key !== "date" && key !== "@comment" && key !== "other";
|
||||
return isDataField(key) && key !== "other";
|
||||
}
|
||||
|
||||
/****
|
||||
|
||||
Reference in New Issue
Block a user