stat-tracker/module/Apps/StatsViewer.mjs
2025-05-30 21:28:15 -06:00

309 lines
7.2 KiB
JavaScript

import { Chart } from "chart.js";
import { diceSizeSorter } from "../utils/sorters/diceSize.mjs";
import { filePath } from "../consts.mjs";
import { Logger } from "../utils/Logger.mjs";
import { PrivacyMode } from "../utils/privacy.mjs";
import { smallToLarge } from "../utils/sorters/smallToLarge.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
export class StatsViewer extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
__ID__,
`StatsViewer`,
],
window: {
title: `Stat Viewer`,
frame: true,
positioned: true,
resizable: true,
minimizable: true,
controls: [
{
label: `Add All Users To Graph`,
action: `addAllUsers`,
},
],
},
position: {
width: 475,
height: 440,
},
actions: {
addAllUsers: this.#addAllUsers,
},
};
static PARTS = {
tableSelect: {
template: filePath(`templates/Apps/common/tableSelect.hbs`),
},
dataFilters: {
template: filePath(`templates/Apps/StatsViewer/dataFilters.hbs`),
},
graph: {
template: filePath(`templates/Apps/StatsViewer/graph.hbs`),
},
};
// #endregion
// #region Instance Data
constructor({ users, privacy, ...opts } = {}) {
super(opts);
if (users != null) {
this._selectedUsers = users;
};
if (privacy != null && Array.isArray(privacy)) {
this._privacySetting = privacy;
};
};
_selectedUsers = [game.user.id];
_graphData = null;
_privacySetting = [PrivacyMode.PUBLIC, PrivacyMode.PRIVATE];
#_selectedTable = ``;
_selectedSubtable = ``;
get _selectedTable() {
return this.#_selectedTable;
};
set _selectedTable(val) {
this.#_selectedTable = val;
this._selectedSubtable = ``;
};
get activeTableID() {
if (this._selectedSubtable) {
return `${this._selectedTable}/${this._selectedSubtable}`;
}
return this._selectedTable;
};
// #endregion Instance Data
// #region Lifecycle
async render({ userUpdated, ...opts } = {}, _options) {
if (userUpdated && !this._selectedUsers.includes(userUpdated)) {
return;
}
await super.render(opts, _options);
};
async _onFirstRender(context, options) {
await super._onFirstRender(context, options);
CONFIG.stats.db.addApp(this);
};
async _onRender(context, options) {
await super._onRender(context, options);
const elements = this.element
.querySelectorAll(`[data-bind]`);
for (const input of elements) {
input.addEventListener(`change`, this.#bindListener.bind(this));
};
if (options.parts.includes(`graph`) && this._graphData) {
const canvas = this.element.querySelector(`canvas`);
new Chart( canvas, this._graphData );
};
};
// #endregion Lifecycle
// #region Data Prep
async _preparePartContext(partId) {
const ctx = {
table: this._selectedTable,
subtable: this._selectedSubtable,
};
ctx.meta = {
idp: this.id,
};
switch (partId) {
case `tableSelect`: {
this.#prepareTableSelectContext(ctx);
break;
};
case `dataFilters`: {
this.#prepareDataFiltersContext(ctx);
break;
};
case `graph`: {
this.#prepareGraphContext(ctx);
break;
};
};
if (import.meta.env.DEV) {
Logger.log(`Context`, ctx);
};
return ctx;
};
async #prepareTableSelectContext(ctx) {
const tables = new Set();
const subtables = {};
for (const tableConfig of await CONFIG.stats.db.getTables()) {
const [ table, subtable ] = tableConfig.name.split(`/`);
tables.add(table);
if (subtable?.length > 0) {
subtables[table] ??= [];
subtables[table].push(subtable);
};
};
const tableList = Array.from(tables);
ctx.table = this._selectedTable;
ctx.tables = tableList;
const subtableList = subtables[this._selectedTable];
// Sort the subtables to be sane
if (this._selectedTable === `Dice`) {
subtableList?.sort(diceSizeSorter);
} else {
subtableList?.sort(smallToLarge);
};
ctx.subtable = this._selectedSubtable;
ctx.subtables = subtableList;
};
async #prepareDataFiltersContext(ctx) {
ctx.users = [];
ctx.selectedUsers = this._selectedUsers;
for (const user of game.users) {
ctx.users.push({
label: user.name,
value: user.id,
});
};
ctx.privacySetting = this._privacySetting;
ctx.privacyOptions = [
{ label: `Self`, value: PrivacyMode.SELF },
{ label: `Private`, value: PrivacyMode.PRIVATE },
{ label: `Public`, value: PrivacyMode.PUBLIC },
];
if (game.user.isGM) {
ctx.privacyOptions.push(
{ label: `Blind`, value: PrivacyMode.GM },
);
};
};
async #prepareGraphContext(ctx) {
const table = await CONFIG.stats.db.getTable(this.activeTableID);
if (!table) {
this._graphData = null;
ctx.showGraph = false;
ctx.classes = `alert-box warning`;
return;
};
ctx.classes = ``;
ctx.showGraph = true;
const userData = await CONFIG.stats.db.getRows(
this.activeTableID,
this._selectedUsers,
this._privacySetting,
);
const data = {};
const allBuckets = new Set();
/*
When we're displaying data for a table within the Dice namespace, we want to
include all values that any user might not have rolled, just for completeness
since we do know exactly what labels to display, this might eventually be
replaced with a per-table configuration setting to determine what values are
populated within the graph and nothing else / prevent non-accepted values
from being added to the table in the first place.
*/
if (this._selectedTable === `Dice`) {
const size = Number.parseInt(this._selectedSubtable.slice(1));
for (let i = 1; i <= size; i++) {
allBuckets.add(i);
};
};
for (const user of this._selectedUsers) {
const buckets = {};
for (const row of userData[user] ?? []) {
buckets[row.value] ??= 0;
buckets[row.value] += 1;
allBuckets.add(row.value);
};
data[user] = buckets;
}
const sortedBucketNames = Array.from(allBuckets).sort(smallToLarge);
const datasets = {};
for (const bucket of sortedBucketNames) {
for (const user of this._selectedUsers) {
datasets[user] ??= [];
datasets[user].push(data[user][bucket] ?? 0);
};
};
this._graphData = {
type: table.graph.type,
options: {
// this must be true because it won't downsize the graph when false
maintainAspectRatio: true,
animation: false,
scales: {
y: {
stacked: table.graph?.stacked ?? false,
},
x: {
stacked: table.graph?.stacked ?? false,
},
},
plugins: {
legend: {
onClick: null,
},
},
},
data: {
labels: sortedBucketNames,
datasets: Object.entries(datasets).map(([userID, values]) => {
const user = game.users.get(userID);
return {
label: user.name,
data: values,
borderWidth: 2,
borderColor: user.color.css,
backgroundColor: user.color.toRGBA(0.5),
};
}),
},
};
};
// #endregion Data Prep
// #region Actions
/**
* @param {Event} event
*/
async #bindListener(event) {
const target = event.target;
const data = target.dataset;
const binding = data.bind;
if (import.meta.env.DEV) {
Logger.debug(`updating ${binding} value to ${target.value}`);
}
Reflect.set(this, binding, target.value);
this.render();
};
/** @this {StatsViewer} */
static async #addAllUsers() {};
// #endregion Actions
};