This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View 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;
}
}

View 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;

View 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)]);
}
}

View 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;
}

View 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);
}

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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;
}
}

View 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();
}
}

View 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);
}

View 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;

View 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;

View 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]);
}
}
}

View 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));
}
}