Start working on the Stats Viewer application

This commit is contained in:
Oliver-Akins 2025-04-21 00:50:58 -06:00
parent cb3bc7c86c
commit 91863d85a8
18 changed files with 422 additions and 1 deletions

128
module/Apps/StatsViewer.mjs Normal file
View file

@ -0,0 +1,128 @@
import { filePath } from "../consts.mjs";
import { Logger } from "../utils/Logger.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,
},
position: {
width: 475,
height: 315,
},
actions: {},
};
static PARTS = {
tableSelect: {
template: filePath(`templates/Apps/StatsViewer/tableSelect.hbs`),
},
dataFilters: {
template: filePath(`templates/Apps/StatsViewer/dataFilters.hbs`),
},
graph: {
template: filePath(`templates/Apps/StatsViewer/graph.hbs`),
},
tableOverview: {
template: filePath(`templates/Apps/StatsViewer/dataOverview.hbs`),
},
};
// #endregion
async _onRender(context, options) {
await super._onRender(context, options);
const { parts } = options;
if (parts.includes(`tableSelect`)) {
this.element
.querySelector(`[data-application-part="tableSelect"] [data-bind]`)
?.addEventListener(`change`, this.#bindListener.bind(this));
};
};
async _preparePartContext(partId) {
const ctx = {};
switch (partId) {
case `tableSelect`: {
this.#prepareTableSelectContext(ctx);
break;
};
};
if (import.meta.env.DEV) {
Logger.log(`Context`, ctx);
};
return ctx;
};
_selectedTable;
_selectedSubtable;
async #prepareTableSelectContext(ctx) {
const tables = new Set();
const subtables = {};
for (const tableConfig of CONFIG.StatsDatabase.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);
this._selectedTable ??= tableList[0];
ctx.table = this._selectedTable;
ctx.tables = tableList;
const subtableList = subtables[this._selectedTable];
if (subtableList && !subtableList.includes(this._selectedSubtable)) {
this._selectedSubtable = subtableList[0];
}
ctx.subtable = this._selectedSubtable;
ctx.subtables = subtableList;
};
/**
* @param {Event} event
*/
async #bindListener(event) {
const target = event.target;
const data = target.dataset;
const binding = data.bind;
if (!binding || !Object.hasOwn(this, binding)) {
return;
};
Logger.log(`updating ${binding} value to ${target.value}`);
this[binding] = target.value;
this.render();
// this.#updatePartContainingElement(target);
};
/**
* @param { HTMLElement } element
*/
#updatePartContainingElement(element) {
const partRoot = element.closest(`[data-application-part]`);
if (!partRoot) { return };
const data = partRoot.dataset;
const partId = data.applicationPart;
this.render({ parts: [partId] });
};
};

58
module/Apps/TestApp.mjs Normal file
View file

@ -0,0 +1,58 @@
import Chart from "chart.js/auto";
import { filePath } from "../consts.mjs";
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
const data = [
{ face: 1, count: Math.floor(Math.random() * 50) },
{ face: 2, count: Math.floor(Math.random() * 50) },
{ face: 3, count: Math.floor(Math.random() * 50) },
{ face: 4, count: Math.floor(Math.random() * 50) },
{ face: 5, count: Math.floor(Math.random() * 50) },
{ face: 6, count: Math.floor(Math.random() * 50) },
];
export class TestApp extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = {
window: {
title: `Dice Pool`,
frame: true,
positioned: true,
resizable: false,
minimizable: true,
},
position: {
width: `auto`,
height: `auto`,
},
actions: {
},
};
static PARTS = {
numberOfDice: {
template: filePath(`templates/Apps/TestApp/main.hbs`),
},
};
// #endregion
_onRender() {
const canvas = this.element.querySelector(`canvas`);
new Chart(
canvas,
{
type: `bar`,
data: {
labels: data.map( r => r.face),
datasets: [
{
label: `d6 Rolls`,
data: data.map(r => r.count),
},
],
},
},
);
};
};

18
module/api.mjs Normal file
View file

@ -0,0 +1,18 @@
import { StatsViewer } from "./Apps/StatsViewer.mjs";
import { TestApp } from "./Apps/TestApp.mjs";
const { deepFreeze } = foundry.utils;
Object.defineProperty(
globalThis,
`stats`,
{
value: deepFreeze({
Apps: {
TestApp,
StatsViewer,
},
}),
writable: false,
},
);

View file

@ -0,0 +1,6 @@
import { options } from "./options.mjs";
export default {
// #region Complex
"st-options": options,
};

View file

@ -0,0 +1,33 @@
/**
* @typedef {object} Option
* @property {string} [label]
* @property {string|number} value
* @property {boolean} [disabled]
*/
/**
* @param {string | number} selected
* @param {Array<Option | string>} opts
* @param {any} meta
*/
export function options(selected, opts) {
selected = Handlebars.escapeExpression(selected);
const htmlOptions = [];
for (let opt of opts) {
if (typeof opt === `string`) {
opt = { label: opt, value: opt };
};
opt.value = Handlebars.escapeExpression(opt.value);
htmlOptions.push(
`<option
value="${opt.value}"
${selected === opt.value ? `selected` : ``}
${opt.disabled ? `disabled` : ``}
>
${ opt.label }
</option>`,
);
};
return new Handlebars.SafeString(htmlOptions.join(`\n`));
};

View file

@ -1,8 +1,19 @@
import { registerMetaSettings } from "../settings/meta.mjs";
import helpers from "../handlebarsHelpers/_index.mjs";
import { Logger } from "../utils/Logger.mjs";
import { MemoryDatabase } from "../utils/databases/Memory.mjs";
import { registerMetaSettings } from "../settings/meta.mjs";
import { UserFlagDatabase } from "../utils/databases/UserFlag.mjs";
Hooks.on(`init`, () => {
Logger.debug(`Initializing`);
registerMetaSettings();
if (import.meta.env.PROD) {
CONFIG.StatsDatabase = UserFlagDatabase;
} else {
CONFIG.StatsDatabase = MemoryDatabase;
}
Handlebars.registerHelper(helpers);
});

View file

@ -1,4 +1,11 @@
export function registerMetaSettings() {
game.settings.register(__ID__, `tables`, {
scope: `world`,
type: Array,
config: false,
requiresReload: false,
});
game.settings.register(__ID__, `data`, {
scope: `user`,
type: Object,

View file

@ -0,0 +1,34 @@
/* eslint-disable no-unused-vars */
import { Table } from "./model.mjs";
export class MemoryDatabase {
static getTables() {
/** @type {Array<{ name: string; }>} */
return [
{ name: `Dice/d4` },
{ name: `Dice/d6` },
{ name: `Dice/d8` },
{ name: `Dice/d10` },
{ name: `Dice/d12` },
{ name: `Dice/d20` },
{ name: `Dice/d100` },
{ name: `Count of Successes` },
];
};
static createRow(table, user, row) {};
static getRows(tableId, ...users) {
if (users.length === 0) { users = [game.user] };
const datasets = {};
return datasets;
};
static updateRow(table, user, rowId, changes) {};
static deleteRow(table, user, rowId) {};
};
/* eslint-enable no-unused-vars */

View file

@ -0,0 +1,36 @@
/* eslint-disable no-unused-vars */
import { Table } from "./model.mjs";
const tablesFlag = `tables`;
export class UserFlagDatabase {
static getTables() {
/** @type {Array<{ name: string; }>} */
const tableConfig = game.settings.get(__ID__, `tables`);
return tableConfig ?? [];
};
static createRow(table, user, row) {};
static getRows(tableId, ...users) {
if (users.length === 0) { users = [game.user] };
const datasets = {};
for (const user of users) {
const tables = user.getFlag(__ID__, tablesFlag) ?? {};
if (tables[tableId] === undefined) {
datasets[user.id] = null;
continue;
};
const table = new Table(tables[tableId]);
}
return datasets;
};
static updateRow(table, user, rowId, changes) {};
static deleteRow(table, user, rowId) {};
};
/* eslint-enable no-unused-vars */

View file

@ -0,0 +1,43 @@
const { fields } = foundry.data;
// MARK: Table
export class Table extends foundry.abstract.DataModel {
static defineSchema() {
return {
name: new fields.StringField({
nullable: false,
required: true,
blank: false,
trim: true,
validate(value) {
return !value.match(/[^a-z0-9_\-:]/i);
},
}),
data: new fields.TypedObjectField(Row),
};
};
};
// MARK: Row
export class Row extends foundry.abstract.DataModel {
static defineSchema() {
return {
id: new fields.StringField({
nullable: false,
required: true,
blank: false,
}),
timestamp: new fields.NumberField({
min: 0,
required: true,
nullable: false,
}),
value: new fields.AnyField(),
private: new fields.BooleanField({
initial: false,
required: true,
nullable: false,
}),
};
};
};