Initial
This commit is contained in:
259
resources/app/common/utils/bitmask.mjs
Normal file
259
resources/app/common/utils/bitmask.mjs
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Create a new BitMask instance.
|
||||
* @param {Record<string, boolean>} [states=null] An object containing valid states and their corresponding initial boolean values (default is null).
|
||||
*/
|
||||
export default class BitMask extends Number {
|
||||
constructor(states=null) {
|
||||
super();
|
||||
this.#generateValidStates(states);
|
||||
this.#generateEnum();
|
||||
this.#value = this.#computeValue(states);
|
||||
}
|
||||
|
||||
/**
|
||||
* The real value behind the bitmask instance.
|
||||
* @type {number}
|
||||
*/
|
||||
#value;
|
||||
|
||||
/**
|
||||
* The structure of valid states and their associated values.
|
||||
* @type {Map<string, number>}
|
||||
*/
|
||||
#validStates;
|
||||
|
||||
/**
|
||||
* The enum associated with this structure.
|
||||
* @type {Record<string, string>}
|
||||
* @readonly
|
||||
*/
|
||||
states;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Internals */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generates the valid states and their associated values.
|
||||
* @param {Record<string, boolean>} [states=null] The structure defining the valid states and their associated values.
|
||||
*/
|
||||
#generateValidStates(states) {
|
||||
this.#validStates = new Map();
|
||||
let bitIndex = 0;
|
||||
for ( const state of Object.keys(states || {}) ) {
|
||||
if ( bitIndex >= 32 ) throw new Error("A bitmask can't handle more than 32 states");
|
||||
this.#validStates.set(state, 1 << bitIndex++);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generates an enum based on the provided valid states.
|
||||
*/
|
||||
#generateEnum() {
|
||||
this.states = {};
|
||||
for ( const state of this.#validStates.keys() ) this.states[state] = state;
|
||||
Object.freeze(this.states);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Calculate the default value of the bitmask based on the initial states
|
||||
* @param {Record<string, boolean>} [initialStates={}] The structure defining the valid states and their associated values.
|
||||
* @returns {number}
|
||||
*/
|
||||
#computeValue(initialStates={}) {
|
||||
let defaultValue = 0;
|
||||
for ( const state in initialStates ) {
|
||||
if ( !initialStates.hasOwnProperty(state) ) continue;
|
||||
this.#checkState(state);
|
||||
if ( initialStates[state] ) defaultValue |= this.#validStates.get(state);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Checks a state and throws an error if it doesn't exist.
|
||||
* @param {string} state Name of the state to check.
|
||||
*/
|
||||
#checkState(state) {
|
||||
if ( !this.#validStates.has(state) ) {
|
||||
throw new Error(`${state} is an invalid state for this BitMask instance: ${this.toJSON()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* True if this bitmask is empty (no active states).
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isEmpty() {
|
||||
return this.#value === 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods for Handling states */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Check if a specific state is active.
|
||||
* @param {string} state The state to check.
|
||||
* @returns {boolean} True if the state is active, false otherwise.
|
||||
*/
|
||||
hasState(state) {
|
||||
return (this.#value & this.#validStates.get(state)) !== 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a state to the bitmask.
|
||||
* @param {string} state The state to add.
|
||||
* @throws {Error} Throws an error if the provided state is not valid.
|
||||
*/
|
||||
addState(state) {
|
||||
this.#checkState(state);
|
||||
this.#value |= this.#validStates.get(state);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a state from the bitmask.
|
||||
* @param {string} state The state to remove.
|
||||
* @throws {Error} Throws an error if the provided state is not valid.
|
||||
*/
|
||||
removeState(state) {
|
||||
this.#checkState(state);
|
||||
this.#value &= ~this.#validStates.get(state);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Toggle the state of a specific state in the bitmask.
|
||||
* @param {string} state The state to toggle.
|
||||
* @param {boolean} [enabled] Toggle on (true) or off (false)? If undefined, the state is switched automatically.
|
||||
* @throws {Error} Throws an error if the provided state is not valid.
|
||||
*/
|
||||
toggleState(state, enabled) {
|
||||
this.#checkState(state);
|
||||
if ( enabled === undefined ) return (this.#value ^= this.#validStates.get(state));
|
||||
if ( enabled ) this.addState(state);
|
||||
else this.removeState(state);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clear the bitmask, setting all states to inactive.
|
||||
*/
|
||||
clear() {
|
||||
this.#value = 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* bitmask representations */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the current value of the bitmask.
|
||||
* @returns {number} The current value of the bitmask.
|
||||
*/
|
||||
valueOf() {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a string representation of the bitmask in binary format.
|
||||
* @returns {string} The string representation of the bitmask.
|
||||
*/
|
||||
toString() {
|
||||
return String(this.#value.toString(2)).padStart(this.#validStates.size, '0');
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Checks if two bitmasks structures are compatible (the same valid states).
|
||||
* @param {BitMask} otherBitMask The bitmask structure to compare with.
|
||||
* @returns {boolean} True if the two bitmasks have the same structure, false otherwise.
|
||||
*/
|
||||
isCompatible(otherBitMask) {
|
||||
const states1 = Array.from(this.#validStates.keys()).sort().join(',');
|
||||
const states2 = Array.from(otherBitMask.#validStates.keys()).sort().join(',');
|
||||
return states1 === states2;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Serializes the bitmask to a JSON string.
|
||||
* @returns {string} The JSON string representing the bitmask.
|
||||
*/
|
||||
toJSON() {
|
||||
return JSON.stringify(this.toObject());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Creates a new BitMask instance from a JSON string.
|
||||
* @param {string} jsonString The JSON string representing the bitmask.
|
||||
* @returns {BitMask} A new BitMask instance created from the JSON string.
|
||||
*/
|
||||
static fromJSON(jsonString) {
|
||||
const data = JSON.parse(jsonString);
|
||||
return new BitMask(data);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert value of this BitMask to object representation according to structure.
|
||||
* @returns {Object} The data represented by the bitmask.
|
||||
*/
|
||||
toObject() {
|
||||
const result = {};
|
||||
for ( const [validState, value] of this.#validStates ) result[validState] = ((this.#value & value) !== 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Creates a clone of this BitMask instance.
|
||||
* @returns {BitMask} A new BitMask instance with the same value and valid states as this instance.
|
||||
*/
|
||||
clone() {
|
||||
return new BitMask(this.toObject());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Static Helpers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generates shader constants based on the provided states.
|
||||
* @param {string[]} states An array containing valid states.
|
||||
* @returns {string} Shader bit mask constants generated from the states.
|
||||
*/
|
||||
static generateShaderBitMaskConstants(states) {
|
||||
let shaderConstants = '';
|
||||
let bitIndex = 0;
|
||||
for ( const state of states ) {
|
||||
shaderConstants += `const uint ${state.toUpperCase()} = 0x${(1 << bitIndex).toString(16).toUpperCase()}U;\n`;
|
||||
bitIndex++;
|
||||
}
|
||||
return shaderConstants;
|
||||
}
|
||||
}
|
||||
227
resources/app/common/utils/collection.mjs
Normal file
227
resources/app/common/utils/collection.mjs
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* A reusable storage concept which blends the functionality of an Array with the efficient key-based lookup of a Map.
|
||||
* This concept is reused throughout Foundry VTT where a collection of uniquely identified elements is required.
|
||||
* @template {string} K
|
||||
* @template {*} V
|
||||
* @extends {Map<K, V>}
|
||||
*/
|
||||
class Collection extends Map {
|
||||
constructor(entries) {
|
||||
super(entries);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Then iterating over a Collection, we should iterate over its values instead of over its entries
|
||||
* @returns {IterableIterator<V>}
|
||||
*/
|
||||
[Symbol.iterator]() {
|
||||
return this.values();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return an Array of all the entry values in the Collection
|
||||
* @type {V[]}
|
||||
*/
|
||||
get contents() {
|
||||
return Array.from(this.values());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Find an entry in the Map using a functional condition.
|
||||
* @see {Array#find}
|
||||
* @param {function(*,number,Collection): boolean} condition The functional condition to test. Positional
|
||||
* arguments are the value, the index of iteration, and the collection being searched.
|
||||
* @return {*} The value, if found, otherwise undefined
|
||||
*
|
||||
* @example Create a new Collection and reference its contents
|
||||
* ```js
|
||||
* let c = new Collection([["a", "A"], ["b", "B"], ["c", "C"]]);
|
||||
* c.get("a") === c.find(entry => entry === "A"); // true
|
||||
* ```
|
||||
*/
|
||||
find(condition) {
|
||||
let i = 0;
|
||||
for ( let v of this.values() ) {
|
||||
if ( condition(v, i, this) ) return v;
|
||||
i++;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Filter the Collection, returning an Array of entries which match a functional condition.
|
||||
* @see {Array#filter}
|
||||
* @param {function(*,number,Collection): boolean} condition The functional condition to test. Positional
|
||||
* arguments are the value, the index of iteration, and the collection being filtered.
|
||||
* @return {Array<*>} An Array of matched values
|
||||
*
|
||||
* @example Filter the Collection for specific entries
|
||||
* ```js
|
||||
* let c = new Collection([["a", "AA"], ["b", "AB"], ["c", "CC"]]);
|
||||
* let hasA = c.filters(entry => entry.slice(0) === "A");
|
||||
* ```
|
||||
*/
|
||||
filter(condition) {
|
||||
const entries = [];
|
||||
let i = 0;
|
||||
for ( let v of this.values() ) {
|
||||
if ( condition(v, i , this) ) entries.push(v);
|
||||
i++;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply a function to each element of the collection
|
||||
* @see Array#forEach
|
||||
* @param {function(*): void} fn A function to apply to each element
|
||||
*
|
||||
* @example Apply a function to each value in the collection
|
||||
* ```js
|
||||
* let c = new Collection([["a", {active: false}], ["b", {active: false}], ["c", {active: false}]]);
|
||||
* c.forEach(e => e.active = true);
|
||||
* ```
|
||||
*/
|
||||
forEach(fn) {
|
||||
for ( let e of this.values() ) {
|
||||
fn(e);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an element from the Collection by its key.
|
||||
* @param {string} key The key of the entry to retrieve
|
||||
* @param {object} [options] Additional options that affect how entries are retrieved
|
||||
* @param {boolean} [options.strict=false] Throw an Error if the requested key does not exist. Default false.
|
||||
* @return {*|undefined} The retrieved entry value, if the key exists, otherwise undefined
|
||||
*
|
||||
* @example Get an element from the Collection by key
|
||||
* ```js
|
||||
* let c = new Collection([["a", "Alfred"], ["b", "Bob"], ["c", "Cynthia"]]);
|
||||
* c.get("a"); // "Alfred"
|
||||
* c.get("d"); // undefined
|
||||
* c.get("d", {strict: true}); // throws Error
|
||||
* ```
|
||||
*/
|
||||
get(key, {strict=false}={}) {
|
||||
const entry = super.get(key);
|
||||
if ( strict && (entry === undefined) ) {
|
||||
throw new Error(`The key ${key} does not exist in the ${this.constructor.name} Collection`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an entry from the Collection by name.
|
||||
* Use of this method assumes that the objects stored in the collection have a "name" attribute.
|
||||
* @param {string} name The name of the entry to retrieve
|
||||
* @param {object} [options] Additional options that affect how entries are retrieved
|
||||
* @param {boolean} [options.strict=false] Throw an Error if the requested name does not exist. Default false.
|
||||
* @return {*} The retrieved entry value, if one was found, otherwise undefined
|
||||
*
|
||||
* @example Get an element from the Collection by name (if applicable)
|
||||
* ```js
|
||||
* let c = new Collection([["a", "Alfred"], ["b", "Bob"], ["c", "Cynthia"]]);
|
||||
* c.getName("Alfred"); // "Alfred"
|
||||
* c.getName("D"); // undefined
|
||||
* c.getName("D", {strict: true}); // throws Error
|
||||
* ```
|
||||
*/
|
||||
getName(name, {strict=false} = {}) {
|
||||
const entry = this.find(e => e.name === name);
|
||||
if ( strict && (entry === undefined) ) {
|
||||
throw new Error(`An entry with name ${name} does not exist in the collection`);
|
||||
}
|
||||
return entry ?? undefined;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Transform each element of the Collection into a new form, returning an Array of transformed values
|
||||
* @param {function(*,number,Collection): *} transformer A transformation function applied to each entry value.
|
||||
* Positional arguments are the value, the index of iteration, and the collection being mapped.
|
||||
* @return {Array<*>} An Array of transformed values
|
||||
*/
|
||||
map(transformer) {
|
||||
const transformed = [];
|
||||
let i = 0;
|
||||
for ( let v of this.values() ) {
|
||||
transformed.push(transformer(v, i, this));
|
||||
i++;
|
||||
}
|
||||
return transformed;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reduce the Collection by applying an evaluator function and accumulating entries
|
||||
* @see {Array#reduce}
|
||||
* @param {function(*,*,number,Collection): *} reducer A reducer function applied to each entry value. Positional
|
||||
* arguments are the accumulator, the value, the index of iteration, and the collection being reduced.
|
||||
* @param {*} initial An initial value which accumulates with each iteration
|
||||
* @return {*} The accumulated result
|
||||
*
|
||||
* @example Reduce a collection to an array of transformed values
|
||||
* ```js
|
||||
* let c = new Collection([["a", "A"], ["b", "B"], ["c", "C"]]);
|
||||
* let letters = c.reduce((s, l) => {
|
||||
* return s + l;
|
||||
* }, ""); // "ABC"
|
||||
* ```
|
||||
*/
|
||||
reduce(reducer, initial) {
|
||||
let accumulator = initial;
|
||||
let i = 0;
|
||||
for ( let v of this.values() ) {
|
||||
accumulator = reducer(accumulator, v, i, this);
|
||||
i++;
|
||||
}
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether a condition is met by some entry in the Collection.
|
||||
* @see {Array#some}
|
||||
* @param {function(*,number,Collection): boolean} condition The functional condition to test. Positional
|
||||
* arguments are the value, the index of iteration, and the collection being tested.
|
||||
* @return {boolean} Was the test condition passed by at least one entry?
|
||||
*/
|
||||
some(condition) {
|
||||
let i = 0;
|
||||
for ( let v of this.values() ) {
|
||||
const pass = condition(v, i, this);
|
||||
i++;
|
||||
if ( pass ) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert the Collection to a primitive array of its contents.
|
||||
* @returns {object[]} An array of contained values
|
||||
*/
|
||||
toJSON() {
|
||||
return this.map(e => e.toJSON ? e.toJSON() : e);
|
||||
}
|
||||
}
|
||||
export default Collection;
|
||||
629
resources/app/common/utils/color.mjs
Normal file
629
resources/app/common/utils/color.mjs
Normal file
@@ -0,0 +1,629 @@
|
||||
|
||||
/**
|
||||
* @typedef {import("../types.mjs").ColorSource} ColorSource
|
||||
*/
|
||||
|
||||
/**
|
||||
* A representation of a color in hexadecimal format.
|
||||
* This class provides methods for transformations and manipulations of colors.
|
||||
*/
|
||||
export default class Color extends Number {
|
||||
|
||||
/**
|
||||
* Is this a valid color?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get valid() {
|
||||
const v = this.valueOf();
|
||||
return Number.isInteger(v) && v >= 0 && v <= 0xFFFFFF;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* A CSS-compatible color string.
|
||||
* If this color is not valid, the empty string is returned.
|
||||
* An alias for Color#toString.
|
||||
* @type {string}
|
||||
*/
|
||||
get css() {
|
||||
return this.toString(16);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The color represented as an RGB array.
|
||||
* @type {[number, number, number]}
|
||||
*/
|
||||
get rgb() {
|
||||
return [((this >> 16) & 0xFF) / 255, ((this >> 8) & 0xFF) / 255, (this & 0xFF) / 255];
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The numeric value of the red channel between [0, 1].
|
||||
* @type {number}
|
||||
*/
|
||||
get r() {
|
||||
return ((this >> 16) & 0xFF) / 255;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The numeric value of the green channel between [0, 1].
|
||||
* @type {number}
|
||||
*/
|
||||
get g() {
|
||||
return ((this >> 8) & 0xFF) / 255;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The numeric value of the blue channel between [0, 1].
|
||||
* @type {number}
|
||||
*/
|
||||
get b() {
|
||||
return (this & 0xFF) / 255;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The maximum value of all channels.
|
||||
* @type {number}
|
||||
*/
|
||||
get maximum() {
|
||||
return Math.max(...this);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The minimum value of all channels.
|
||||
* @type {number}
|
||||
*/
|
||||
get minimum() {
|
||||
return Math.min(...this);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Get the value of this color in little endian format.
|
||||
* @type {number}
|
||||
*/
|
||||
get littleEndian() {
|
||||
return ((this >> 16) & 0xFF) + (this & 0x00FF00) + ((this & 0xFF) << 16);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The color represented as an HSV array.
|
||||
* Conversion formula adapted from http://en.wikipedia.org/wiki/HSV_color_space.
|
||||
* Assumes r, g, and b are contained in the set [0, 1] and returns h, s, and v in the set [0, 1].
|
||||
* @type {[number, number, number]}
|
||||
*/
|
||||
get hsv() {
|
||||
const [r, g, b] = this.rgb;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const d = max - min;
|
||||
|
||||
let h;
|
||||
const s = max === 0 ? 0 : d / max;
|
||||
const v = max;
|
||||
|
||||
// Achromatic colors
|
||||
if (max === min) return [0, s, v];
|
||||
|
||||
// Normal colors
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
||||
case g: h = (b - r) / d + 2; break;
|
||||
case b: h = (r - g) / d + 4; break;
|
||||
}
|
||||
h /= 6;
|
||||
return [h, s, v];
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The color represented as an HSL array.
|
||||
* Assumes r, g, and b are contained in the set [0, 1] and returns h, s, and l in the set [0, 1].
|
||||
* @type {[number, number, number]}
|
||||
*/
|
||||
get hsl() {
|
||||
const [r, g, b] = this.rgb;
|
||||
|
||||
// Compute luminosity, saturation and hue
|
||||
const l = Math.max(r, g, b);
|
||||
const s = l - Math.min(r, g, b);
|
||||
let h = 0;
|
||||
if ( s > 0 ) {
|
||||
if ( l === r ) {
|
||||
h = (g - b) / s;
|
||||
} else if ( l === g ) {
|
||||
h = 2 + (b - r) / s;
|
||||
} else {
|
||||
h = 4 + (r - g) / s;
|
||||
}
|
||||
}
|
||||
const finalHue = (60 * h < 0 ? 60 * h + 360 : 60 * h) / 360;
|
||||
const finalSaturation = s ? (l <= 0.5 ? s / (2 * l - s) : s / (2 - (2 * l - s))) : 0;
|
||||
const finalLuminance = (2 * l - s) / 2;
|
||||
return [finalHue, finalSaturation, finalLuminance];
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The color represented as a linear RGB array.
|
||||
* Assumes r, g, and b are contained in the set [0, 1] and returns linear r, g, and b in the set [0, 1].
|
||||
* @link https://en.wikipedia.org/wiki/SRGB#Transformation
|
||||
* @type {Color}
|
||||
*/
|
||||
get linear() {
|
||||
const toLinear = c => (c > 0.04045) ? Math.pow((c + 0.055) / 1.055, 2.4) : (c / 12.92);
|
||||
return this.constructor.fromRGB([toLinear(this.r), toLinear(this.g), toLinear(this.b)]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
/* Color Manipulation Methods */
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/** @override */
|
||||
toString(radix) {
|
||||
if ( !this.valid ) return "";
|
||||
return `#${super.toString(16).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Serialize the Color.
|
||||
* @returns {string} The color as a CSS string
|
||||
*/
|
||||
toJSON() {
|
||||
return this.css;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Returns the color as a CSS string.
|
||||
* @returns {string} The color as a CSS string
|
||||
*/
|
||||
toHTML() {
|
||||
return this.css;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Test whether this color equals some other color
|
||||
* @param {Color|number} other Some other color or hex number
|
||||
* @returns {boolean} Are the colors equal?
|
||||
*/
|
||||
equals(other) {
|
||||
return this.valueOf() === other.valueOf();
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Get a CSS-compatible RGBA color string.
|
||||
* @param {number} alpha The desired alpha in the range [0, 1]
|
||||
* @returns {string} A CSS-compatible RGBA string
|
||||
*/
|
||||
toRGBA(alpha) {
|
||||
const rgba = [(this >> 16) & 0xFF, (this >> 8) & 0xFF, this & 0xFF, alpha]
|
||||
return `rgba(${rgba.join(", ")})`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Mix this Color with some other Color using a provided interpolation weight.
|
||||
* @param {Color} other Some other Color to mix with
|
||||
* @param {number} weight The mixing weight placed on this color where weight is placed on the other color
|
||||
* @returns {Color} The resulting mixed Color
|
||||
*/
|
||||
mix(other, weight) {
|
||||
return new Color(Color.mix(this, other, weight));
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Multiply this Color by another Color or a static scalar.
|
||||
* @param {Color|number} other Some other Color or a static scalar.
|
||||
* @returns {Color} The resulting Color.
|
||||
*/
|
||||
multiply(other) {
|
||||
if ( other instanceof Color ) return new Color(Color.multiply(this, other));
|
||||
return new Color(Color.multiplyScalar(this, other));
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Add this Color by another Color or a static scalar.
|
||||
* @param {Color|number} other Some other Color or a static scalar.
|
||||
* @returns {Color} The resulting Color.
|
||||
*/
|
||||
add(other) {
|
||||
if ( other instanceof Color ) return new Color(Color.add(this, other));
|
||||
return new Color(Color.addScalar(this, other));
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Subtract this Color by another Color or a static scalar.
|
||||
* @param {Color|number} other Some other Color or a static scalar.
|
||||
* @returns {Color} The resulting Color.
|
||||
*/
|
||||
subtract(other) {
|
||||
if ( other instanceof Color ) return new Color(Color.subtract(this, other));
|
||||
return new Color(Color.subtractScalar(this, other));
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Max this color by another Color or a static scalar.
|
||||
* @param {Color|number} other Some other Color or a static scalar.
|
||||
* @returns {Color} The resulting Color.
|
||||
*/
|
||||
maximize(other) {
|
||||
if ( other instanceof Color ) return new Color(Color.maximize(this, other));
|
||||
return new Color(Color.maximizeScalar(this, other));
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Min this color by another Color or a static scalar.
|
||||
* @param {Color|number} other Some other Color or a static scalar.
|
||||
* @returns {Color} The resulting Color.
|
||||
*/
|
||||
minimize(other) {
|
||||
if ( other instanceof Color ) return new Color(Color.minimize(this, other));
|
||||
return new Color(Color.minimizeScalar(this, other));
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
/* Iterator */
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Iterating over a Color is equivalent to iterating over its [r,g,b] color channels.
|
||||
* @returns {Generator<number>}
|
||||
*/
|
||||
*[Symbol.iterator]() {
|
||||
yield this.r;
|
||||
yield this.g;
|
||||
yield this.b;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------------------------- */
|
||||
/* Real-time performance Methods and Properties */
|
||||
/* Important Note: */
|
||||
/* These methods are not a replacement, but a tool when real-time performance is needed. */
|
||||
/* They do not have the flexibility of the "classic" methods and come with some limitations. */
|
||||
/* Unless you have to deal with real-time performance, you should use the "classic" methods. */
|
||||
/* ------------------------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set an rgb array with the rgb values contained in this Color class.
|
||||
* @param {number[]} vec3 Receive the result. Must be an array with at least a length of 3.
|
||||
*/
|
||||
applyRGB(vec3) {
|
||||
vec3[0] = ((this >> 16) & 0xFF) / 255;
|
||||
vec3[1] = ((this >> 8) & 0xFF) / 255;
|
||||
vec3[2] = (this & 0xFF) / 255;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Apply a linear interpolation between two colors, according to the weight.
|
||||
* @param {number} color1 The first color to mix.
|
||||
* @param {number} color2 The second color to mix.
|
||||
* @param {number} weight Weight of the linear interpolation.
|
||||
* @returns {number} The resulting mixed color
|
||||
*/
|
||||
static mix(color1, color2, weight) {
|
||||
return (((((color1 >> 16) & 0xFF) * (1 - weight) + ((color2 >> 16) & 0xFF) * weight) << 16) & 0xFF0000)
|
||||
| (((((color1 >> 8) & 0xFF) * (1 - weight) + ((color2 >> 8) & 0xFF) * weight) << 8) & 0x00FF00)
|
||||
| (((color1 & 0xFF) * (1 - weight) + (color2 & 0xFF) * weight) & 0x0000FF);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Multiply two colors.
|
||||
* @param {number} color1 The first color to multiply.
|
||||
* @param {number} color2 The second color to multiply.
|
||||
* @returns {number} The result.
|
||||
*/
|
||||
static multiply(color1, color2) {
|
||||
return ((((color1 >> 16) & 0xFF) / 255 * ((color2 >> 16) & 0xFF) / 255) * 255 << 16)
|
||||
| ((((color1 >> 8) & 0xFF) / 255 * ((color2 >> 8) & 0xFF) / 255) * 255 << 8)
|
||||
| (((color1 & 0xFF) / 255 * ((color2 & 0xFF) / 255)) * 255);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Multiply a color by a scalar
|
||||
* @param {number} color The color to multiply.
|
||||
* @param {number} scalar A static scalar to multiply with.
|
||||
* @returns {number} The resulting color as a number.
|
||||
*/
|
||||
static multiplyScalar(color, scalar) {
|
||||
return (Math.clamp(((color >> 16) & 0xFF) / 255 * scalar, 0, 1) * 255 << 16)
|
||||
| (Math.clamp(((color >> 8) & 0xFF) / 255 * scalar, 0, 1) * 255 << 8)
|
||||
| (Math.clamp((color & 0xFF) / 255 * scalar, 0, 1) * 255);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Maximize two colors.
|
||||
* @param {number} color1 The first color.
|
||||
* @param {number} color2 The second color.
|
||||
* @returns {number} The result.
|
||||
*/
|
||||
static maximize(color1, color2) {
|
||||
return (Math.clamp(Math.max((color1 >> 16) & 0xFF, (color2 >> 16) & 0xFF), 0, 0xFF) << 16)
|
||||
| (Math.clamp(Math.max((color1 >> 8) & 0xFF, (color2 >> 8) & 0xFF), 0, 0xFF) << 8)
|
||||
| Math.clamp(Math.max(color1 & 0xFF, color2 & 0xFF), 0, 0xFF);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Maximize a color by a static scalar.
|
||||
* @param {number} color The color to maximize.
|
||||
* @param {number} scalar Scalar to maximize with (normalized).
|
||||
* @returns {number} The resulting color as a number.
|
||||
*/
|
||||
static maximizeScalar(color, scalar) {
|
||||
return (Math.clamp(Math.max((color >> 16) & 0xFF, scalar * 255), 0, 0xFF) << 16)
|
||||
| (Math.clamp(Math.max((color >> 8) & 0xFF, scalar * 255), 0, 0xFF) << 8)
|
||||
| Math.clamp(Math.max(color & 0xFF, scalar * 255), 0, 0xFF);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Add two colors.
|
||||
* @param {number} color1 The first color.
|
||||
* @param {number} color2 The second color.
|
||||
* @returns {number} The resulting color as a number.
|
||||
*/
|
||||
static add(color1, color2) {
|
||||
return (Math.clamp((((color1 >> 16) & 0xFF) + ((color2 >> 16) & 0xFF)), 0, 0xFF) << 16)
|
||||
| (Math.clamp((((color1 >> 8) & 0xFF) + ((color2 >> 8) & 0xFF)), 0, 0xFF) << 8)
|
||||
| Math.clamp(((color1 & 0xFF) + (color2 & 0xFF)), 0, 0xFF);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Add a static scalar to a color.
|
||||
* @param {number} color The color.
|
||||
* @param {number} scalar Scalar to add with (normalized).
|
||||
* @returns {number} The resulting color as a number.
|
||||
*/
|
||||
static addScalar(color, scalar) {
|
||||
return (Math.clamp((((color >> 16) & 0xFF) + scalar * 255), 0, 0xFF) << 16)
|
||||
| (Math.clamp((((color >> 8) & 0xFF) + scalar * 255), 0, 0xFF) << 8)
|
||||
| Math.clamp(((color & 0xFF) + scalar * 255), 0, 0xFF);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Subtract two colors.
|
||||
* @param {number} color1 The first color.
|
||||
* @param {number} color2 The second color.
|
||||
*/
|
||||
static subtract(color1, color2) {
|
||||
return (Math.clamp((((color1 >> 16) & 0xFF) - ((color2 >> 16) & 0xFF)), 0, 0xFF) << 16)
|
||||
| (Math.clamp((((color1 >> 8) & 0xFF) - ((color2 >> 8) & 0xFF)), 0, 0xFF) << 8)
|
||||
| Math.clamp(((color1 & 0xFF) - (color2 & 0xFF)), 0, 0xFF);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Subtract a color by a static scalar.
|
||||
* @param {number} color The color.
|
||||
* @param {number} scalar Scalar to subtract with (normalized).
|
||||
* @returns {number} The resulting color as a number.
|
||||
*/
|
||||
static subtractScalar(color, scalar) {
|
||||
return (Math.clamp((((color >> 16) & 0xFF) - scalar * 255), 0, 0xFF) << 16)
|
||||
| (Math.clamp((((color >> 8) & 0xFF) - scalar * 255), 0, 0xFF) << 8)
|
||||
| Math.clamp(((color & 0xFF) - scalar * 255), 0, 0xFF);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Minimize two colors.
|
||||
* @param {number} color1 The first color.
|
||||
* @param {number} color2 The second color.
|
||||
*/
|
||||
static minimize(color1, color2) {
|
||||
return (Math.clamp(Math.min((color1 >> 16) & 0xFF, (color2 >> 16) & 0xFF), 0, 0xFF) << 16)
|
||||
| (Math.clamp(Math.min((color1 >> 8) & 0xFF, (color2 >> 8) & 0xFF), 0, 0xFF) << 8)
|
||||
| Math.clamp(Math.min(color1 & 0xFF, color2 & 0xFF), 0, 0xFF);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Minimize a color by a static scalar.
|
||||
* @param {number} color The color.
|
||||
* @param {number} scalar Scalar to minimize with (normalized).
|
||||
*/
|
||||
static minimizeScalar(color, scalar) {
|
||||
return (Math.clamp(Math.min((color >> 16) & 0xFF, scalar * 255), 0, 0xFF) << 16)
|
||||
| (Math.clamp(Math.min((color >> 8) & 0xFF, scalar * 255), 0, 0xFF) << 8)
|
||||
| Math.clamp(Math.min(color & 0xFF, scalar * 255), 0, 0xFF);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Convert a color to RGB and assign values to a passed array.
|
||||
* @param {number} color The color to convert to RGB values.
|
||||
* @param {number[]} vec3 Receive the result. Must be an array with at least a length of 3.
|
||||
*/
|
||||
static applyRGB(color, vec3) {
|
||||
vec3[0] = ((color >> 16) & 0xFF) / 255;
|
||||
vec3[1] = ((color >> 8) & 0xFF) / 255;
|
||||
vec3[2] = (color & 0xFF) / 255;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
/* Factory Methods */
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Create a Color instance from an RGB array.
|
||||
* @param {ColorSource} color A color input
|
||||
* @returns {Color} The hex color instance or NaN
|
||||
*/
|
||||
static from(color) {
|
||||
if ( (color === null) || (color === undefined) ) return new this(NaN);
|
||||
if ( typeof color === "string" ) return this.fromString(color);
|
||||
if ( typeof color === "number" ) return new this(color);
|
||||
if ( (color instanceof Array) && (color.length === 3) ) return this.fromRGB(color);
|
||||
if ( color instanceof Color ) return color;
|
||||
return new this(color);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Create a Color instance from a color string which either includes or does not include a leading #.
|
||||
* @param {string} color A color string
|
||||
* @returns {Color} The hex color instance
|
||||
*/
|
||||
static fromString(color) {
|
||||
return new this(parseInt(color.startsWith("#") ? color.substring(1) : color, 16));
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Create a Color instance from an RGB array.
|
||||
* @param {[number, number, number]} rgb An RGB tuple
|
||||
* @returns {Color} The hex color instance
|
||||
*/
|
||||
static fromRGB(rgb) {
|
||||
return new this(((rgb[0] * 255) << 16) + ((rgb[1] * 255) << 8) + (rgb[2] * 255 | 0));
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Create a Color instance from an RGB normalized values.
|
||||
* @param {number} r The red value
|
||||
* @param {number} g The green value
|
||||
* @param {number} b The blue value
|
||||
* @returns {Color} The hex color instance
|
||||
*/
|
||||
static fromRGBvalues(r, g, b) {
|
||||
return new this(((r * 255) << 16) + ((g * 255) << 8) + (b * 255 | 0));
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Create a Color instance from an HSV array.
|
||||
* Conversion formula adapted from http://en.wikipedia.org/wiki/HSV_color_space.
|
||||
* Assumes h, s, and v are contained in the set [0, 1].
|
||||
* @param {[number, number, number]} hsv An HSV tuple
|
||||
* @returns {Color} The hex color instance
|
||||
*/
|
||||
static fromHSV(hsv) {
|
||||
const [h, s, v] = hsv;
|
||||
const i = Math.floor(h * 6);
|
||||
const f = (h * 6) - i;
|
||||
const p = v * (1 - s);
|
||||
const q = v * (1 - f * s);
|
||||
const t = v * (1 - (1 - f) * s);
|
||||
let rgb;
|
||||
switch (i % 6) {
|
||||
case 0: rgb = [v, t, p]; break;
|
||||
case 1: rgb = [q, v, p]; break;
|
||||
case 2: rgb = [p, v, t]; break;
|
||||
case 3: rgb = [p, q, v]; break;
|
||||
case 4: rgb = [t, p, v]; break;
|
||||
case 5: rgb = [v, p, q]; break;
|
||||
}
|
||||
return this.fromRGB(rgb);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Create a Color instance from an HSL array.
|
||||
* Assumes h, s, and l are contained in the set [0, 1].
|
||||
* @param {[number, number, number]} hsl An HSL tuple
|
||||
* @returns {Color} The hex color instance
|
||||
*/
|
||||
static fromHSL(hsl) {
|
||||
const [h, s, l] = hsl;
|
||||
|
||||
// Calculate intermediate values for the RGB components
|
||||
const chroma = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const hue = h * 6;
|
||||
const x = chroma * (1 - Math.abs(hue % 2 - 1));
|
||||
const m = l - chroma / 2;
|
||||
|
||||
let r, g, b;
|
||||
switch (Math.floor(hue)) {
|
||||
case 0: [r, g, b] = [chroma, x, 0]; break;
|
||||
case 1: [r, g, b] = [x, chroma, 0]; break;
|
||||
case 2: [r, g, b] = [0, chroma, x]; break;
|
||||
case 3: [r, g, b] = [0, x, chroma]; break;
|
||||
case 4: [r, g, b] = [x, 0, chroma]; break;
|
||||
case 5:
|
||||
case 6:[r, g, b] = [chroma, 0, x]; break;
|
||||
default: [r, g, b] = [0, 0, 0]; break;
|
||||
}
|
||||
|
||||
// Adjust for luminance
|
||||
r += m;
|
||||
g += m;
|
||||
b += m;
|
||||
return this.fromRGB([r, g, b]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Create a Color instance (sRGB) from a linear rgb array.
|
||||
* Assumes r, g, and b are contained in the set [0, 1].
|
||||
* @link https://en.wikipedia.org/wiki/SRGB#Transformation
|
||||
* @param {[number, number, number]} linear The linear rgb array
|
||||
* @returns {Color} The hex color instance
|
||||
*/
|
||||
static fromLinearRGB(linear) {
|
||||
const [r, g, b] = linear;
|
||||
const tosrgb = c => (c <= 0.0031308) ? (12.92 * c) : (1.055 * Math.pow(c, 1 / 2.4) - 0.055);
|
||||
return this.fromRGB([tosrgb(r), tosrgb(g), tosrgb(b)]);
|
||||
}
|
||||
}
|
||||
107
resources/app/common/utils/event-emitter.mjs
Normal file
107
resources/app/common/utils/event-emitter.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @typedef {import("../types.mjs").Constructor} Constructor
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback EmittedEventListener
|
||||
* @param {Event} event The emitted event
|
||||
* @returns {any}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Augment a base class with EventEmitter behavior.
|
||||
* @template {Constructor} BaseClass
|
||||
* @param {BaseClass} BaseClass Some base class augmented with event emitter functionality
|
||||
*/
|
||||
export default function EventEmitterMixin(BaseClass) {
|
||||
/**
|
||||
* A mixin class which implements the behavior of EventTarget.
|
||||
* This is useful in cases where a class wants EventTarget-like behavior but needs to extend some other class.
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget
|
||||
*/
|
||||
class EventEmitter extends BaseClass {
|
||||
|
||||
/**
|
||||
* An array of event types which are valid for this class.
|
||||
* @type {string[]}
|
||||
*/
|
||||
static emittedEvents = [];
|
||||
|
||||
/**
|
||||
* A mapping of registered events.
|
||||
* @type {Record<string, Map<EmittedEventListener, {fn: EmittedEventListener, once: boolean}>>}
|
||||
*/
|
||||
#events = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a new event listener for a certain type of event.
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
|
||||
* @param {string} type The type of event being registered for
|
||||
* @param {EmittedEventListener} listener The listener function called when the event occurs
|
||||
* @param {object} [options={}] Options which configure the event listener
|
||||
* @param {boolean} [options.once=false] Should the event only be responded to once and then removed
|
||||
*/
|
||||
addEventListener(type, listener, {once = false} = {}) {
|
||||
if ( !this.constructor.emittedEvents.includes(type) ) {
|
||||
throw new Error(`"${type}" is not a supported event of the ${this.constructor.name} class`);
|
||||
}
|
||||
this.#events[type] ||= new Map();
|
||||
this.#events[type].set(listener, {fn: listener, once});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove an event listener for a certain type of event.
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
|
||||
* @param {string} type The type of event being removed
|
||||
* @param {EmittedEventListener} listener The listener function being removed
|
||||
*/
|
||||
removeEventListener(type, listener) {
|
||||
this.#events[type]?.delete(listener);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Dispatch an event on this target.
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent
|
||||
* @param {Event} event The Event to dispatch
|
||||
* @returns {boolean} Was default behavior for the event prevented?
|
||||
*/
|
||||
dispatchEvent(event) {
|
||||
if ( !(event instanceof Event) ) {
|
||||
throw new Error("EventEmitter#dispatchEvent must be provided an Event instance");
|
||||
}
|
||||
if ( !this.constructor.emittedEvents.includes(event?.type) ) {
|
||||
throw new Error(`"${event.type}" is not a supported event of the ${this.constructor.name} class`);
|
||||
}
|
||||
const listeners = this.#events[event.type];
|
||||
if ( !listeners ) return true;
|
||||
|
||||
// Extend and configure the Event
|
||||
Object.defineProperties(event, {
|
||||
target: {value: this},
|
||||
stopPropagation: {value: function() {
|
||||
event.propagationStopped = true;
|
||||
Event.prototype.stopPropagation.call(this);
|
||||
}},
|
||||
stopImmediatePropagation: {value: function() {
|
||||
event.propagationStopped = true;
|
||||
Event.prototype.stopImmediatePropagation.call(this);
|
||||
}}
|
||||
});
|
||||
|
||||
// Call registered listeners
|
||||
for ( const listener of listeners.values() ) {
|
||||
listener.fn(event);
|
||||
if ( listener.once ) this.removeEventListener(event.type, listener.fn);
|
||||
if ( event.propagationStopped ) break;
|
||||
}
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
}
|
||||
return EventEmitter;
|
||||
}
|
||||
373
resources/app/common/utils/geometry.mjs
Normal file
373
resources/app/common/utils/geometry.mjs
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Determine the relative orientation of three points in two-dimensional space.
|
||||
* The result is also an approximation of twice the signed area of the triangle defined by the three points.
|
||||
* This method is fast - but not robust against issues of floating point precision. Best used with integer coordinates.
|
||||
* Adapted from https://github.com/mourner/robust-predicates.
|
||||
* @param {Point} a An endpoint of segment AB, relative to which point C is tested
|
||||
* @param {Point} b An endpoint of segment AB, relative to which point C is tested
|
||||
* @param {Point} c A point that is tested relative to segment AB
|
||||
* @returns {number} The relative orientation of points A, B, and C
|
||||
* A positive value if the points are in counter-clockwise order (C lies to the left of AB)
|
||||
* A negative value if the points are in clockwise order (C lies to the right of AB)
|
||||
* Zero if the points A, B, and C are collinear.
|
||||
*/
|
||||
export function orient2dFast(a, b, c) {
|
||||
return (a.y - c.y) * (b.x - c.x) - (a.x - c.x) * (b.y - c.y);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Quickly test whether the line segment AB intersects with the line segment CD.
|
||||
* This method does not determine the point of intersection, for that use lineLineIntersection.
|
||||
* @param {Point} a The first endpoint of segment AB
|
||||
* @param {Point} b The second endpoint of segment AB
|
||||
* @param {Point} c The first endpoint of segment CD
|
||||
* @param {Point} d The second endpoint of segment CD
|
||||
* @returns {boolean} Do the line segments intersect?
|
||||
*/
|
||||
export function lineSegmentIntersects(a, b, c, d) {
|
||||
|
||||
// First test the orientation of A and B with respect to CD to reject collinear cases
|
||||
const xa = foundry.utils.orient2dFast(a, b, c);
|
||||
const xb = foundry.utils.orient2dFast(a, b, d);
|
||||
if ( !xa && !xb ) return false;
|
||||
const xab = (xa * xb) <= 0;
|
||||
|
||||
// Also require an intersection of CD with respect to AB
|
||||
const xcd = (foundry.utils.orient2dFast(c, d, a) * foundry.utils.orient2dFast(c, d, b)) <= 0;
|
||||
return xab && xcd;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Object} LineIntersection
|
||||
* @property {number} x The x-coordinate of intersection
|
||||
* @property {number} y The y-coordinate of intersection
|
||||
* @property {number} t0 The vector distance from A to B on segment AB
|
||||
* @property {number} [t1] The vector distance from C to D on segment CD
|
||||
*/
|
||||
|
||||
/**
|
||||
* An internal helper method for computing the intersection between two infinite-length lines.
|
||||
* Adapted from http://paulbourke.net/geometry/pointlineplane/.
|
||||
* @param {Point} a The first endpoint of segment AB
|
||||
* @param {Point} b The second endpoint of segment AB
|
||||
* @param {Point} c The first endpoint of segment CD
|
||||
* @param {Point} d The second endpoint of segment CD
|
||||
* @param {object} [options] Options which affect the intersection test
|
||||
* @param {boolean} [options.t1=false] Return the optional vector distance from C to D on CD
|
||||
* @returns {LineIntersection|null} An intersection point, or null if no intersection occurred
|
||||
*/
|
||||
export function lineLineIntersection(a, b, c, d, {t1=false}={}) {
|
||||
|
||||
// If either line is length 0, they cannot intersect
|
||||
if (((a.x === b.x) && (a.y === b.y)) || ((c.x === d.x) && (c.y === d.y))) return null;
|
||||
|
||||
// Check denominator - avoid parallel lines where d = 0
|
||||
const dnm = ((d.y - c.y) * (b.x - a.x) - (d.x - c.x) * (b.y - a.y));
|
||||
if (dnm === 0) return null;
|
||||
|
||||
// Vector distances
|
||||
const t0 = ((d.x - c.x) * (a.y - c.y) - (d.y - c.y) * (a.x - c.x)) / dnm;
|
||||
t1 = t1 ? ((b.x - a.x) * (a.y - c.y) - (b.y - a.y) * (a.x - c.x)) / dnm : undefined;
|
||||
|
||||
// Return the point of intersection
|
||||
return {
|
||||
x: a.x + t0 * (b.x - a.x),
|
||||
y: a.y + t0 * (b.y - a.y),
|
||||
t0: t0,
|
||||
t1: t1
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* An internal helper method for computing the intersection between two finite line segments.
|
||||
* Adapted from http://paulbourke.net/geometry/pointlineplane/
|
||||
* @param {Point} a The first endpoint of segment AB
|
||||
* @param {Point} b The second endpoint of segment AB
|
||||
* @param {Point} c The first endpoint of segment CD
|
||||
* @param {Point} d The second endpoint of segment CD
|
||||
* @param {number} [epsilon] A small epsilon which defines a tolerance for near-equality
|
||||
* @returns {LineIntersection|null} An intersection point, or null if no intersection occurred
|
||||
*/
|
||||
export function lineSegmentIntersection(a, b, c, d, epsilon=1e-8) {
|
||||
|
||||
// If either line is length 0, they cannot intersect
|
||||
if (((a.x === b.x) && (a.y === b.y)) || ((c.x === d.x) && (c.y === d.y))) return null;
|
||||
|
||||
// Check denominator - avoid parallel lines where d = 0
|
||||
const dnm = ((d.y - c.y) * (b.x - a.x) - (d.x - c.x) * (b.y - a.y));
|
||||
if (dnm === 0) return null;
|
||||
|
||||
// Vector distance from a
|
||||
const t0 = ((d.x - c.x) * (a.y - c.y) - (d.y - c.y) * (a.x - c.x)) / dnm;
|
||||
if ( !Number.between(t0, 0-epsilon, 1+epsilon) ) return null;
|
||||
|
||||
// Vector distance from c
|
||||
const t1 = ((b.x - a.x) * (a.y - c.y) - (b.y - a.y) * (a.x - c.x)) / dnm;
|
||||
if ( !Number.between(t1, 0-epsilon, 1+epsilon) ) return null;
|
||||
|
||||
// Return the point of intersection and the vector distance from both line origins
|
||||
return {
|
||||
x: a.x + t0 * (b.x - a.x),
|
||||
y: a.y + t0 * (b.y - a.y),
|
||||
t0: Math.clamp(t0, 0, 1),
|
||||
t1: Math.clamp(t1, 0, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Object} LineCircleIntersection
|
||||
* @property {boolean} aInside Is point A inside the circle?
|
||||
* @property {boolean} bInside Is point B inside the circle?
|
||||
* @property {boolean} contained Is the segment AB contained within the circle?
|
||||
* @property {boolean} outside Is the segment AB fully outside the circle?
|
||||
* @property {boolean} tangent Is the segment AB tangent to the circle?
|
||||
* @property {Point[]} intersections Intersection points: zero, one, or two
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determine the intersection between a line segment and a circle.
|
||||
* @param {Point} a The first vertex of the segment
|
||||
* @param {Point} b The second vertex of the segment
|
||||
* @param {Point} center The center of the circle
|
||||
* @param {number} radius The radius of the circle
|
||||
* @param {number} epsilon A small tolerance for floating point precision
|
||||
* @returns {LineCircleIntersection} The intersection of the segment AB with the circle
|
||||
*/
|
||||
export function lineCircleIntersection(a, b, center, radius, epsilon=1e-8) {
|
||||
const r2 = Math.pow(radius, 2);
|
||||
let intersections = [];
|
||||
|
||||
// Test whether endpoint A is contained
|
||||
const ar2 = Math.pow(a.x - center.x, 2) + Math.pow(a.y - center.y, 2);
|
||||
const aInside = ar2 < r2 - epsilon;
|
||||
|
||||
// Test whether endpoint B is contained
|
||||
const br2 = Math.pow(b.x - center.x, 2) + Math.pow(b.y - center.y, 2);
|
||||
const bInside = br2 < r2 - epsilon;
|
||||
|
||||
// Find quadratic intersection points
|
||||
const contained = aInside && bInside;
|
||||
if ( !contained ) intersections = quadraticIntersection(a, b, center, radius, epsilon);
|
||||
|
||||
// Return the intersection data
|
||||
return {
|
||||
aInside,
|
||||
bInside,
|
||||
contained,
|
||||
outside: !contained && !intersections.length,
|
||||
tangent: !aInside && !bInside && intersections.length === 1,
|
||||
intersections
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Identify the point closest to C on segment AB
|
||||
* @param {Point} c The reference point C
|
||||
* @param {Point} a Point A on segment AB
|
||||
* @param {Point} b Point B on segment AB
|
||||
* @returns {Point} The closest point to C on segment AB
|
||||
*/
|
||||
export function closestPointToSegment(c, a, b) {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
if (( dx === 0 ) && ( dy === 0 )) {
|
||||
throw new Error("Zero-length segment AB not supported");
|
||||
}
|
||||
const u = (((c.x - a.x) * dx) + ((c.y - a.y) * dy)) / (dx * dx + dy * dy);
|
||||
if ( u < 0 ) return a;
|
||||
if ( u > 1 ) return b;
|
||||
else return {
|
||||
x: a.x + (u * dx),
|
||||
y: a.y + (u * dy)
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Determine the points of intersection between a line segment (p0,p1) and a circle.
|
||||
* There will be zero, one, or two intersections
|
||||
* See https://math.stackexchange.com/a/311956.
|
||||
* @param {Point} p0 The initial point of the line segment
|
||||
* @param {Point} p1 The terminal point of the line segment
|
||||
* @param {Point} center The center of the circle
|
||||
* @param {number} radius The radius of the circle
|
||||
* @param {number} [epsilon=0] A small tolerance for floating point precision
|
||||
*/
|
||||
export function quadraticIntersection(p0, p1, center, radius, epsilon=0) {
|
||||
const dx = p1.x - p0.x;
|
||||
const dy = p1.y - p0.y;
|
||||
|
||||
// Quadratic terms where at^2 + bt + c = 0
|
||||
const a = Math.pow(dx, 2) + Math.pow(dy, 2);
|
||||
const b = (2 * dx * (p0.x - center.x)) + (2 * dy * (p0.y - center.y));
|
||||
const c = Math.pow(p0.x - center.x, 2) + Math.pow(p0.y - center.y, 2) - Math.pow(radius, 2);
|
||||
|
||||
// Discriminant
|
||||
let disc2 = Math.pow(b, 2) - (4 * a * c);
|
||||
if ( disc2.almostEqual(0) ) disc2 = 0; // segment endpoint touches the circle; 1 intersection
|
||||
else if ( disc2 < 0 ) return []; // no intersections
|
||||
|
||||
// Roots
|
||||
const disc = Math.sqrt(disc2);
|
||||
const t1 = (-b - disc) / (2 * a);
|
||||
|
||||
// If t1 hits (between 0 and 1) it indicates an "entry"
|
||||
const intersections = [];
|
||||
if ( t1.between(0-epsilon, 1+epsilon) ) {
|
||||
intersections.push({
|
||||
x: p0.x + (dx * t1),
|
||||
y: p0.y + (dy * t1)
|
||||
});
|
||||
}
|
||||
if ( !disc2 ) return intersections; // 1 intersection
|
||||
|
||||
// If t2 hits (between 0 and 1) it indicates an "exit"
|
||||
const t2 = (-b + disc) / (2 * a);
|
||||
if ( t2.between(0-epsilon, 1+epsilon) ) {
|
||||
intersections.push({
|
||||
x: p0.x + (dx * t2),
|
||||
y: p0.y + (dy * t2)
|
||||
});
|
||||
}
|
||||
return intersections;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Calculate the centroid non-self-intersecting closed polygon.
|
||||
* See https://en.wikipedia.org/wiki/Centroid#Of_a_polygon.
|
||||
* @param {Point[]|number[]} points The points of the polygon
|
||||
* @returns {Point} The centroid of the polygon
|
||||
*/
|
||||
export function polygonCentroid(points) {
|
||||
const n = points.length;
|
||||
if ( n === 0 ) return {x: 0, y: 0};
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let a = 0;
|
||||
if ( typeof points[0] === "number" ) {
|
||||
let x0 = points[n - 2];
|
||||
let y0 = points[n - 1];
|
||||
for ( let i = 0; i < n; i += 2 ) {
|
||||
const x1 = points[i];
|
||||
const y1 = points[i + 1];
|
||||
const z = (x0 * y1) - (x1 * y0);
|
||||
x += (x0 + x1) * z;
|
||||
y += (y0 + y1) * z;
|
||||
x0 = x1;
|
||||
y0 = y1;
|
||||
a += z;
|
||||
}
|
||||
} else {
|
||||
let {x: x0, y: y0} = points[n - 1];
|
||||
for ( let i = 0; i < n; i++ ) {
|
||||
const {x: x1, y: y1} = points[i];
|
||||
const z = (x0 * y1) - (x1 * y0);
|
||||
x += (x0 + x1) * z;
|
||||
y += (y0 + y1) * z;
|
||||
x0 = x1;
|
||||
y0 = y1;
|
||||
a += z;
|
||||
}
|
||||
}
|
||||
a *= 3;
|
||||
x /= a;
|
||||
y /= a;
|
||||
return {x, y};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether the circle given by the center and radius intersects the path (open or closed).
|
||||
* @param {Point[]|number[]} points The points of the path
|
||||
* @param {boolean} close If true, the edge from the last to the first point is tested
|
||||
* @param {Point} center The center of the circle
|
||||
* @param {number} radius The radius of the circle
|
||||
* @returns {boolean} Does the circle intersect the path?
|
||||
*/
|
||||
export function pathCircleIntersects(points, close, center, radius) {
|
||||
const n = points.length;
|
||||
if ( n === 0 ) return false;
|
||||
const {x: cx, y: cy} = center;
|
||||
const rr = radius * radius;
|
||||
let i;
|
||||
let x0;
|
||||
let y0;
|
||||
if ( typeof points[0] === "number" ) {
|
||||
if ( close ) {
|
||||
i = 0;
|
||||
x0 = points[n - 2];
|
||||
y0 = points[n - 1];
|
||||
} else {
|
||||
i = 2;
|
||||
x0 = points[0];
|
||||
y0 = points[1];
|
||||
}
|
||||
for ( ; i < n; i += 2 ) {
|
||||
const x1 = points[i];
|
||||
const y1 = points[i + 1];
|
||||
let dx = cx - x0;
|
||||
let dy = cy - y0;
|
||||
const nx = x1 - x0;
|
||||
const ny = y1 - y0;
|
||||
const t = Math.clamp(((dx * nx) + (dy * ny)) / ((nx * nx) + (ny * ny)), 0, 1);
|
||||
dx = (t * nx) - dx;
|
||||
dy = (t * ny) - dy;
|
||||
if ( (dx * dx) + (dy * dy) <= rr ) return true;
|
||||
x0 = x1;
|
||||
y0 = y1;
|
||||
}
|
||||
} else {
|
||||
if ( close ) {
|
||||
i = 0;
|
||||
({x: x0, y: y0} = points[n - 1]);
|
||||
} else {
|
||||
i = 1;
|
||||
({x: x0, y: y0} = points[0]);
|
||||
}
|
||||
for ( ; i < n; i++ ) {
|
||||
const {x: x2, y: y2} = points[i];
|
||||
let dx = cx - x0;
|
||||
let dy = cy - y0;
|
||||
const nx = x1 - x0;
|
||||
const ny = y1 - y0;
|
||||
const t = Math.clamp(((dx * nx) + (dy * ny)) / ((nx * nx) + (ny * ny)), 0, 1);
|
||||
dx = (t * nx) - dx;
|
||||
dy = (t * ny) - dy;
|
||||
if ( (dx * dx) + (dy * dy) <= rr ) return true;
|
||||
x0 = x2;
|
||||
y0 = y2;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether two circles (with position and radius) intersect.
|
||||
* @param {number} x0 x center coordinate of circle A.
|
||||
* @param {number} y0 y center coordinate of circle A.
|
||||
* @param {number} r0 radius of circle A.
|
||||
* @param {number} x1 x center coordinate of circle B.
|
||||
* @param {number} y1 y center coordinate of circle B.
|
||||
* @param {number} r1 radius of circle B.
|
||||
* @returns {boolean} True if the two circles intersect, false otherwise.
|
||||
*/
|
||||
export function circleCircleIntersects(x0, y0, r0, x1, y1, r1) {
|
||||
return Math.hypot(x0 - x1, y0 - y1) <= (r0 + r1);
|
||||
}
|
||||
|
||||
1019
resources/app/common/utils/helpers.mjs
Normal file
1019
resources/app/common/utils/helpers.mjs
Normal file
File diff suppressed because it is too large
Load Diff
87
resources/app/common/utils/http.mjs
Normal file
87
resources/app/common/utils/http.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* A wrapper method around `fetch` that attaches an AbortController signal to the `fetch` call for clean timeouts
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal#aborting_a_fetch_with_timeout_or_explicit_abort
|
||||
* @param {string} url The URL to make the Request to
|
||||
* @param {Object} data The data of the Request
|
||||
* @param {number|null} timeoutMs How long to wait for a Response before cleanly aborting.
|
||||
* If null, no timeout is applied
|
||||
* @param {function} onTimeout A method to invoke if and when the timeout is reached
|
||||
* @return {Promise<Response>}
|
||||
* @throws {HttpError}
|
||||
*/
|
||||
export async function fetchWithTimeout(url, data = {}, {timeoutMs=30000, onTimeout = () => {}} = {}) {
|
||||
const controller = new AbortController();
|
||||
data.signal = controller.signal;
|
||||
let timedOut = false;
|
||||
const enforceTimeout = timeoutMs !== null;
|
||||
|
||||
// Enforce a timeout
|
||||
let timeout;
|
||||
if ( enforceTimeout ) {
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
onTimeout();
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
// Attempt the request
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, data);
|
||||
} catch(err) {
|
||||
if ( timedOut ) {
|
||||
const timeoutS = Math.round(timeoutMs / 1000);
|
||||
const msg = game.i18n
|
||||
? game.i18n.format("SETUP.ErrorTimeout", { url, timeout: timeoutS })
|
||||
: `The request to ${url} timed out after ${timeoutS}s.`;
|
||||
throw new HttpError("Timed Out", 408, msg);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if ( enforceTimeout ) clearTimeout(timeout);
|
||||
}
|
||||
|
||||
// Return the response
|
||||
if ( !response.ok && (response.type !== "opaqueredirect") ) {
|
||||
const responseBody = response.body ? await response.text() : "";
|
||||
throw new HttpError(response.statusText, response.status, responseBody);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* A small wrapper that automatically asks for JSON with a Timeout
|
||||
* @param {string} url The URL to make the Request to
|
||||
* @param {Object} data The data of the Request
|
||||
* @param {int} timeoutMs How long to wait for a Response before cleanly aborting
|
||||
* @param {function} onTimeout A method to invoke if and when the timeout is reached
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
export async function fetchJsonWithTimeout(url, data = {}, {timeoutMs=30000, onTimeout = () => {}} = {}) {
|
||||
let response = await fetchWithTimeout(url, data, {timeoutMs: timeoutMs, onTimeout: onTimeout});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Represents an HTTP Error when a non-OK response is returned by Fetch
|
||||
* @extends {Error}
|
||||
*/
|
||||
export class HttpError extends Error {
|
||||
constructor(statusText, code, displayMessage="") {
|
||||
super(statusText);
|
||||
this.code = code;
|
||||
this.displayMessage = displayMessage;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
toString() {
|
||||
return this.displayMessage;
|
||||
}
|
||||
}
|
||||
151
resources/app/common/utils/iterable-weak-map.mjs
Normal file
151
resources/app/common/utils/iterable-weak-map.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Stores a map of objects with weak references to the keys, allowing them to be garbage collected. Both keys and values
|
||||
* can be iterated over, unlike a WeakMap.
|
||||
*/
|
||||
export default class IterableWeakMap extends WeakMap {
|
||||
/**
|
||||
* @typedef {object} IterableWeakMapHeldValue
|
||||
* @property {Set<WeakRef<any>>} set The set to be cleaned.
|
||||
* @property {WeakRef<any>} ref The ref to remove.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} IterableWeakMapValue
|
||||
* @property {any} value The value.
|
||||
* @property {WeakRef<any>} ref The weak ref of the key.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A set of weak refs to the map's keys, allowing enumeration.
|
||||
* @type {Set<WeakRef<any>>}
|
||||
*/
|
||||
#refs = new Set();
|
||||
|
||||
/**
|
||||
* A FinalizationRegistry instance to clean up the ref set when objects are garbage collected.
|
||||
* @type {FinalizationRegistry<IterableWeakMapHeldValue>}
|
||||
*/
|
||||
#finalizer = new FinalizationRegistry(IterableWeakMap.#cleanup);
|
||||
|
||||
/**
|
||||
* @param {Iterable<[any, any]>} [entries] The initial entries.
|
||||
*/
|
||||
constructor(entries=[]) {
|
||||
super();
|
||||
for ( const [key, value] of entries ) this.set(key, value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clean up the corresponding ref in the set when its value is garbage collected.
|
||||
* @param {IterableWeakMapHeldValue} heldValue The value held by the finalizer.
|
||||
*/
|
||||
static #cleanup({ set, ref }) {
|
||||
set.delete(ref);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Remove a key from the map.
|
||||
* @param {any} key The key to remove.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
delete(key) {
|
||||
const entry = super.get(key);
|
||||
if ( !entry ) return false;
|
||||
super.delete(key);
|
||||
this.#refs.delete(entry.ref);
|
||||
this.#finalizer.unregister(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve a value from the map.
|
||||
* @param {any} key The value's key.
|
||||
* @returns {any}
|
||||
*/
|
||||
get(key) {
|
||||
const entry = super.get(key);
|
||||
return entry && entry.value;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Place a value in the map.
|
||||
* @param {any} key The key.
|
||||
* @param {any} value The value.
|
||||
* @returns {IterableWeakMap}
|
||||
*/
|
||||
set(key, value) {
|
||||
const entry = super.get(key);
|
||||
if ( entry ) this.#refs.delete(entry.ref);
|
||||
const ref = new WeakRef(key);
|
||||
super.set(key, { value, ref });
|
||||
this.#refs.add(ref);
|
||||
this.#finalizer.register(key, { ref, set: this.#refs }, key);
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clear all values from the map.
|
||||
*/
|
||||
clear() {
|
||||
for ( const ref of this.#refs ) {
|
||||
const key = ref.deref();
|
||||
if ( key ) this.delete(key);
|
||||
else this.#refs.delete(ref);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Enumerate the entries.
|
||||
* @returns {Generator<[any, any], void, any>}
|
||||
*/
|
||||
*[Symbol.iterator]() {
|
||||
for ( const ref of this.#refs ) {
|
||||
const key = ref.deref();
|
||||
if ( !key ) continue;
|
||||
const { value } = super.get(key);
|
||||
yield [key, value];
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Enumerate the entries.
|
||||
* @returns {Generator<[any, any], void, any>}
|
||||
*/
|
||||
entries() {
|
||||
return this[Symbol.iterator]();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Enumerate the keys.
|
||||
* @returns {Generator<any, void, any>}
|
||||
*/
|
||||
*keys() {
|
||||
for ( const [key] of this ) yield key;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Enumerate the values.
|
||||
* @returns {Generator<any, void, any>}
|
||||
*/
|
||||
*values() {
|
||||
for ( const [, value] of this ) yield value;
|
||||
}
|
||||
}
|
||||
84
resources/app/common/utils/iterable-weak-set.mjs
Normal file
84
resources/app/common/utils/iterable-weak-set.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
import IterableWeakMap from "./iterable-weak-map.mjs";
|
||||
|
||||
/**
|
||||
* Stores a set of objects with weak references to them, allowing them to be garbage collected. Can be iterated over,
|
||||
* unlike a WeakSet.
|
||||
*/
|
||||
export default class IterableWeakSet extends WeakSet {
|
||||
/**
|
||||
* The backing iterable weak map.
|
||||
* @type {IterableWeakMap<any, any>}
|
||||
*/
|
||||
#map = new IterableWeakMap();
|
||||
|
||||
/**
|
||||
* @param {Iterable<any>} [entries] The initial entries.
|
||||
*/
|
||||
constructor(entries=[]) {
|
||||
super();
|
||||
for ( const entry of entries ) this.add(entry);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Enumerate the values.
|
||||
* @returns {Generator<any, void, any>}
|
||||
*/
|
||||
[Symbol.iterator]() {
|
||||
return this.values();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a value to the set.
|
||||
* @param {any} value The value to add.
|
||||
* @returns {IterableWeakSet}
|
||||
*/
|
||||
add(value) {
|
||||
this.#map.set(value, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Delete a value from the set.
|
||||
* @param {any} value The value to delete.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
delete(value) {
|
||||
return this.#map.delete(value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether this set contains the given value.
|
||||
* @param {any} value The value to test.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has(value) {
|
||||
return this.#map.has(value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Enumerate the collection.
|
||||
* @returns {Generator<any, void, any>}
|
||||
*/
|
||||
values() {
|
||||
return this.#map.values();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Clear all values from the set.
|
||||
*/
|
||||
clear() {
|
||||
this.#map.clear();
|
||||
}
|
||||
}
|
||||
60
resources/app/common/utils/logging.mjs
Normal file
60
resources/app/common/utils/logging.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
import {COMPATIBILITY_MODES} from "../constants.mjs";
|
||||
|
||||
/**
|
||||
* The messages that have been logged already and should not be logged again.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
const loggedCompatibilityWarnings = new Set();
|
||||
|
||||
/**
|
||||
* Log a compatibility warning which is filtered based on the client's defined compatibility settings.
|
||||
* @param {string} message The original warning or error message
|
||||
* @param {object} [options={}] Additional options which customize logging
|
||||
* @param {number} [options.mode] A logging level in COMPATIBILITY_MODES which overrides the configured default
|
||||
* @param {number|string} [options.since] A version identifier since which a change was made
|
||||
* @param {number|string} [options.until] A version identifier until which a change remains supported
|
||||
* @param {string} [options.details] Additional details to append to the logged message
|
||||
* @param {boolean} [options.stack=true] Include the message stack trace
|
||||
* @param {boolean} [options.once=false] Log this the message only once?
|
||||
* @throws An Error if the mode is ERROR
|
||||
*/
|
||||
export function logCompatibilityWarning(message, {mode, since, until, details, stack=true, once=false}={}) {
|
||||
|
||||
// Determine the logging mode
|
||||
const modes = COMPATIBILITY_MODES;
|
||||
const compatibility = globalThis.CONFIG?.compatibility || {
|
||||
mode: modes.WARNING,
|
||||
includePatterns: [],
|
||||
excludePatterns: []
|
||||
};
|
||||
mode ??= compatibility.mode;
|
||||
if ( mode === modes.SILENT ) return;
|
||||
|
||||
// Compose the message
|
||||
since = since ? `Deprecated since Version ${since}` : null;
|
||||
until = until ? `Backwards-compatible support will be removed in Version ${until}`: null;
|
||||
message = [message, since, until, details].filterJoin("\n");
|
||||
|
||||
// Filter the message by its stack trace
|
||||
const error = new Error(message);
|
||||
if ( compatibility.includePatterns.length ) {
|
||||
if ( !compatibility.includePatterns.some(rgx => rgx.test(error.message) || rgx.test(error.stack)) ) return;
|
||||
}
|
||||
if ( compatibility.excludePatterns.length ) {
|
||||
if ( compatibility.excludePatterns.some(rgx => rgx.test(error.message) || rgx.test(error.stack)) ) return;
|
||||
}
|
||||
|
||||
// Log the message
|
||||
const log = !(once && loggedCompatibilityWarnings.has(error.stack));
|
||||
switch ( mode ) {
|
||||
case modes.WARNING:
|
||||
if ( log ) globalThis.logger.warn(stack ? error : error.message);
|
||||
break;
|
||||
case modes.ERROR:
|
||||
if ( log ) globalThis.logger.error(stack ? error : error.message);
|
||||
break;
|
||||
case modes.FAILURE:
|
||||
throw error;
|
||||
}
|
||||
if ( log && once ) loggedCompatibilityWarnings.add(error.stack);
|
||||
}
|
||||
19
resources/app/common/utils/module.mjs
Normal file
19
resources/app/common/utils/module.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
export * from "./geometry.mjs";
|
||||
export * from "./helpers.mjs";
|
||||
export * from "./http.mjs";
|
||||
export * from "./logging.mjs";
|
||||
export {default as Collection} from "./collection.mjs";
|
||||
export {default as EventEmitterMixin} from "./event-emitter.mjs";
|
||||
export {default as IterableWeakSet} from "./iterable-weak-set.mjs";
|
||||
export {default as IterableWeakMap} from "./iterable-weak-map.mjs";
|
||||
export {default as Color} from "./color.mjs";
|
||||
export {default as Semaphore} from "./semaphore.mjs";
|
||||
export {default as BitMask} from "./bitmask.mjs";
|
||||
export {default as WordTree} from "./word-tree.mjs";
|
||||
export {default as StringTree} from "./string-tree.mjs";
|
||||
|
||||
/**
|
||||
* The constructor of an async function.
|
||||
* @type {typeof AsyncFunction}
|
||||
*/
|
||||
export const AsyncFunction = (async function() {}).constructor;
|
||||
113
resources/app/common/utils/semaphore.mjs
Normal file
113
resources/app/common/utils/semaphore.mjs
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* A simple Semaphore implementation which provides a limited queue for ensuring proper concurrency.
|
||||
* @param {number} [max=1] The maximum number of tasks which are allowed concurrently.
|
||||
*
|
||||
* @example Using a Semaphore
|
||||
* ```js
|
||||
* // Some async function that takes time to execute
|
||||
* function fn(x) {
|
||||
* return new Promise(resolve => {
|
||||
* setTimeout(() => {
|
||||
* console.log(x);
|
||||
* resolve(x);
|
||||
* }, 1000));
|
||||
* }
|
||||
* };
|
||||
*
|
||||
* // Create a Semaphore and add many concurrent tasks
|
||||
* const semaphore = new Semaphore(1);
|
||||
* for ( let i of Array.fromRange(100) ) {
|
||||
* semaphore.add(fn, i);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class Semaphore {
|
||||
constructor(max=1) {
|
||||
|
||||
/**
|
||||
* The maximum number of tasks which can be simultaneously attempted.
|
||||
* @type {number}
|
||||
*/
|
||||
this.max = max;
|
||||
|
||||
/**
|
||||
* A queue of pending function signatures
|
||||
* @type {Array<Array<Function|*>>}
|
||||
* @private
|
||||
*/
|
||||
this._queue = [];
|
||||
|
||||
/**
|
||||
* The number of tasks which are currently underway
|
||||
* @type {number}
|
||||
* @private
|
||||
*/
|
||||
this._active = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of pending tasks remaining in the queue
|
||||
* @type {number}
|
||||
*/
|
||||
get remaining() {
|
||||
return this._queue.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of actively executing tasks
|
||||
* @type {number}
|
||||
*/
|
||||
get active() {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new tasks to the managed queue
|
||||
* @param {Function} fn A callable function
|
||||
* @param {...*} [args] Function arguments
|
||||
* @returns {Promise} A promise that resolves once the added function is executed
|
||||
*/
|
||||
add(fn, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._queue.push([fn, args, resolve, reject]);
|
||||
return this._try();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Abandon any tasks which have not yet concluded
|
||||
*/
|
||||
clear() {
|
||||
this._queue = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to perform a task from the queue.
|
||||
* If all workers are busy, do nothing.
|
||||
* If successful, try again.
|
||||
* @private
|
||||
*/
|
||||
async _try() {
|
||||
if ( (this.active === this.max) || !this.remaining ) return false;
|
||||
|
||||
// Obtain the next task from the queue
|
||||
const next = this._queue.shift();
|
||||
if ( !next ) return;
|
||||
this._active += 1;
|
||||
|
||||
// Try and execute it, resolving its promise
|
||||
const [fn, args, resolve, reject] = next;
|
||||
try {
|
||||
const r = await fn(...args);
|
||||
resolve(r);
|
||||
}
|
||||
catch(err) {
|
||||
reject(err);
|
||||
}
|
||||
|
||||
// Try the next function in the queue
|
||||
this._active -= 1;
|
||||
return this._try();
|
||||
}
|
||||
}
|
||||
export default Semaphore;
|
||||
132
resources/app/common/utils/string-tree.mjs
Normal file
132
resources/app/common/utils/string-tree.mjs
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* A string tree node consists of zero-or-more string keys, and a leaves property that contains any objects that
|
||||
* terminate at the current node.
|
||||
* @typedef {object} StringTreeNode
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback StringTreeEntryFilter
|
||||
* @param {any} entry The entry to filter.
|
||||
* @returns {boolean} Whether the entry should be included in the result set.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A data structure representing a tree of string nodes with arbitrary object leaves.
|
||||
*/
|
||||
export default class StringTree {
|
||||
/**
|
||||
* The key symbol that stores the leaves of any given node.
|
||||
* @type {symbol}
|
||||
*/
|
||||
static get leaves() {
|
||||
return StringTree.#leaves;
|
||||
}
|
||||
|
||||
static #leaves = Symbol();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The tree's root.
|
||||
* @type {StringTreeNode}
|
||||
*/
|
||||
#root = this.#createNode();
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a new node.
|
||||
* @returns {StringTreeNode}
|
||||
*/
|
||||
#createNode() {
|
||||
return { [StringTree.leaves]: [] };
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Insert an entry into the tree.
|
||||
* @param {string[]} strings The string parents for the entry.
|
||||
* @param {any} entry The entry to store.
|
||||
* @returns {StringTreeNode} The node the entry was added to.
|
||||
*/
|
||||
addLeaf(strings, entry) {
|
||||
let node = this.#root;
|
||||
for ( const string of strings ) {
|
||||
node[string] ??= this.#createNode();
|
||||
node = node[string];
|
||||
}
|
||||
|
||||
// Once we've traversed the tree, we add our entry.
|
||||
node[StringTree.leaves].push(entry);
|
||||
return node;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Traverse the tree along the given string path and return any entries reachable from the node.
|
||||
* @param {string[]} strings The string path to the desired node.
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.limit] The maximum number of items to retrieve.
|
||||
* @param {StringTreeEntryFilter} [options.filterEntries] A filter function to apply to each candidate entry.
|
||||
* @returns {any[]}
|
||||
*/
|
||||
lookup(strings, { limit, filterEntries }={}) {
|
||||
const entries = [];
|
||||
const node = this.nodeAtPrefix(strings);
|
||||
if ( !node ) return []; // No matching entries.
|
||||
const queue = [node];
|
||||
while ( queue.length ) {
|
||||
if ( limit && (entries.length >= limit) ) break;
|
||||
this._breadthFirstSearch(queue.shift(), entries, queue, { limit, filterEntries });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns the node at the given path through the tree.
|
||||
* @param {string[]} strings The string path to the desired node.
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.hasLeaves=false] Only return the most recently visited node that has leaves, otherwise
|
||||
* return the exact node at the prefix, if it exists.
|
||||
* @returns {StringTreeNode|void}
|
||||
*/
|
||||
nodeAtPrefix(strings, { hasLeaves=false }={}) {
|
||||
let node = this.#root;
|
||||
let withLeaves = node;
|
||||
for ( const string of strings ) {
|
||||
if ( !(string in node) ) return hasLeaves ? withLeaves : undefined;
|
||||
node = node[string];
|
||||
if ( node[StringTree.leaves].length ) withLeaves = node;
|
||||
}
|
||||
return hasLeaves ? withLeaves : node;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform a breadth-first search starting from the given node and retrieving any entries reachable from that node,
|
||||
* until we reach the limit.
|
||||
* @param {StringTreeNode} node The starting node.
|
||||
* @param {any[]} entries The accumulated entries.
|
||||
* @param {StringTreeNode[]} queue The working queue of nodes to search.
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.limit] The maximum number of entries to retrieve before stopping.
|
||||
* @param {StringTreeEntryFilter} [options.filterEntries] A filter function to apply to each candidate entry.
|
||||
* @protected
|
||||
*/
|
||||
_breadthFirstSearch(node, entries, queue, { limit, filterEntries }={}) {
|
||||
// Retrieve the entries at this node.
|
||||
let leaves = node[StringTree.leaves];
|
||||
if ( filterEntries instanceof Function ) leaves = leaves.filter(filterEntries);
|
||||
entries.push(...leaves);
|
||||
if ( limit && (entries.length >= limit) ) return;
|
||||
// Push this node's children onto the end of the queue.
|
||||
for ( const key of Object.keys(node) ) {
|
||||
if ( typeof key === "string" ) queue.push(node[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
resources/app/common/utils/word-tree.mjs
Normal file
60
resources/app/common/utils/word-tree.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
import StringTree from "./string-tree.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./string-tree.mjs").StringTreeNode} StringTreeNode
|
||||
*/
|
||||
|
||||
/**
|
||||
* A leaf entry in the tree.
|
||||
* @typedef {object} WordTreeEntry
|
||||
* @property {Document|object} entry An object that this entry represents.
|
||||
* @property {string} documentName The document type.
|
||||
* @property {string} uuid The document's UUID.
|
||||
* @property {string} [pack] The pack ID.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A data structure for quickly retrieving objects by a string prefix.
|
||||
* Note that this works well for languages with alphabets (latin, cyrillic, korean, etc.), but may need more nuanced
|
||||
* handling for languages that compose characters and letters.
|
||||
* @extends {StringTree}
|
||||
*/
|
||||
export default class WordTree extends StringTree {
|
||||
/**
|
||||
* Insert an entry into the tree.
|
||||
* @param {string} string The string key for the entry.
|
||||
* @param {WordTreeEntry} entry The entry to store.
|
||||
* @returns {StringTreeNode} The node the entry was added to.
|
||||
*/
|
||||
addLeaf(string, entry) {
|
||||
string = string.toLocaleLowerCase(game.i18n.lang);
|
||||
return super.addLeaf(Array.from(string), entry);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return entries that match the given string prefix.
|
||||
* @param {string} prefix The prefix.
|
||||
* @param {object} [options] Additional options to configure behaviour.
|
||||
* @param {number} [options.limit=10] The maximum number of items to retrieve. It is important to set this value as
|
||||
* very short prefixes will naturally match large numbers of entries.
|
||||
* @param {StringTreeEntryFilter} [options.filterEntries] A filter function to apply to each candidate entry.
|
||||
* @returns {WordTreeEntry[]} A number of entries that have the given prefix.
|
||||
*/
|
||||
lookup(prefix, { limit=10, filterEntries }={}) {
|
||||
return super.lookup(prefix, { limit, filterEntries });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Returns the node at the given prefix.
|
||||
* @param {string} prefix The prefix.
|
||||
* @returns {StringTreeNode}
|
||||
*/
|
||||
nodeAtPrefix(prefix) {
|
||||
prefix = prefix.toLocaleLowerCase(game.i18n.lang);
|
||||
return super.nodeAtPrefix(Array.from(prefix));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user