OpenHPS/openhps-core

View on GitHub
src/utils/unit/Unit.ts

Summary

Maintainability
C
1 day
Test Coverage
import 'reflect-metadata';
import { SerializableObject, SerializableMember } from '../../data/decorators';
import { UnitOptions } from './UnitOptions';
import { UnitBasicDefinition, UnitDefinition, UnitFunctionDefinition, UnitValueType } from './UnitDefinition';
import { UnitPrefix, UnitPrefixType } from './UnitPrefix';
import { Vector3 } from '../math/Vector3';

/**
 * Unit
 *
 * ## Usage
 * ### Creation
 * ```typescript
 * const myUnit = new Unit("meter", {
 * baseName: "length",
 * aliases: ["m", "meters"],
 * prefixes: 'decimal'
 * })
 * ```
 *
 * ### Specifiers
 * You can specify the prefix using the ```specifier(...)``` function.
 * ```typescript
 * const nanoUnit = myUnit.specifier(UnitPrefix.NANO);
 * ```
 * @category Unit
 */
@SerializableObject({
    initializer: Unit.fromJSON,
})
export class Unit {
    private _name: string;
    private _baseName: string;
    private _definitions: Map<string, UnitFunctionDefinition<any, any>> = new Map();
    private _prefixType: UnitPrefixType = 'none';
    private _aliases: string[] = [];

    // Unit bases (e.g. length, time, velocity, ...)
    protected static readonly UNIT_BASES: Map<string, string> = new Map();
    // Units (e.g. second, meter, ...)
    protected static readonly UNITS: Map<string, Unit> = new Map();

    static readonly UNKNOWN = new Unit('unknown');

    /**
     * Create a new unit
     * @param {string} name Unit name
     * @param {UnitOptions} options Unit options
     */
    constructor(name?: string, options?: UnitOptions) {
        const config: UnitOptions = options || { baseName: undefined };
        config.aliases = config.aliases || [];
        config.prefixes = config.prefixes || 'none';
        config.definitions = config.definitions || [];

        // Unit config
        this._name = name || config.name;
        this._baseName = config.baseName;
        this._aliases = config.aliases;
        this._prefixType = config.prefixes;

        // Unit definitions
        config.definitions.forEach(this._initDefinition.bind(this));

        if (this.name) {
            Unit.registerUnit(this, config.override);
        }
    }

    /**
     * Get a unit from JSON
     * @param {any} json JSON object
     * @returns {Unit} Unit if found
     */
    static fromJSON<T extends Unit | Unit>(json: any): T {
        if (json.name !== undefined) {
            const unit = Unit.findByName(json.name);
            if (!unit) {
                throw new Error(`Unit with name '${json.name}' not found! Unable to deserialize!`);
            }
            return unit as T;
        } else {
            throw new Error(`Unit does not define a serialization name! Unable to deserialize!`);
        }
    }

    private _initDefinition(definition: UnitDefinition): void {
        const referenceUnit = Unit.findByName(definition.unit, this.baseName);
        const unitName = referenceUnit ? referenceUnit.name : definition.unit;

        if ('toUnit' in definition) {
            // UnitFunctionDefinition
            this._initFunctionDefinition(definition, unitName);
        } else {
            // UnitBasicDefinition
            this._initBasicDefinition(definition, unitName);
        }
    }

    private _initFunctionDefinition(definition: UnitDefinition, unitName: string): void {
        const functionDefinition = definition as UnitFunctionDefinition<any, any>;
        this._definitions.set(unitName, functionDefinition);
    }

    private _initBasicDefinition(definition: UnitDefinition, unitName: string): void {
        const definitionKeys = Object.keys(definition);
        const basicDefinition: UnitBasicDefinition = definition;
        const magnitudeOrder = definitionKeys.indexOf('magnitude');
        const offsetOrder = definitionKeys.indexOf('offset');
        const magnitude = basicDefinition.magnitude || 1;
        const offset = basicDefinition.offset !== undefined ? basicDefinition.offset : 0;
        const offsetPriority = magnitudeOrder === -1 ? true : offsetOrder < magnitudeOrder;

        let toUnitFn: (value: number) => number;
        let fromUnitFn: (value: number) => number;

        if (offsetPriority) {
            toUnitFn = (value: number) => (value + offset) * magnitude;
            fromUnitFn = (value: number) => value / magnitude - offset;
        } else {
            toUnitFn = (value: number) => value * magnitude + offset;
            fromUnitFn = (value: number) => (value - offset) / magnitude;
        }

        this._definitions.set(unitName, {
            unit: basicDefinition.unit,
            toUnit: toUnitFn,
            fromUnit: fromUnitFn,
        });
    }

    /**
     * Unit name
     * @returns {string} Name
     */
    @SerializableMember()
    get name(): string {
        return this._name;
    }

    set name(name: string) {
        this._name = name;
        const existingUnit = Unit.findByName(name);
        if (existingUnit) {
            this._baseName = existingUnit.baseName;
            this._definitions = existingUnit._definitions;
            this._prefixType = existingUnit._prefixType;
            this._aliases = existingUnit._aliases;
        }
    }

    /**
     * Unit aliases
     * @returns {string[]} Alias names as array
     */
    get aliases(): string[] {
        return this._aliases;
    }

    get baseName(): string {
        return this._baseName;
    }

    get prefixType(): UnitPrefixType {
        return this._prefixType;
    }

    get definitions(): UnitDefinition[] {
        return Array.from(this._definitions.values());
    }

    protected get prefixes(): UnitPrefix[] {
        switch (this._prefixType) {
            case 'decimal':
                return UnitPrefix.DECIMAL;
            case 'none':
                return [];
        }
    }

    /**
     * Get or create a definition from this unit to the base
     * @returns {UnitFunctionDefinition} Definition to base
     */
    createBaseDefinition(): UnitFunctionDefinition<any, any> {
        let newDefinition: UnitFunctionDefinition<any, any>;

        // Get base unit
        const baseUnitName = Unit.UNIT_BASES.get(this.baseName);

        if (this._definitions.has(baseUnitName)) {
            const definition = this._definitions.get(baseUnitName);
            newDefinition = definition;
        } else {
            this._definitions.forEach((definition) => {
                const unit = Unit.findByName(definition.unit, this.baseName);
                const baseDefinition = unit.createBaseDefinition();

                if (baseDefinition) {
                    newDefinition = {
                        unit: baseDefinition.unit,
                        toUnit: (value: any) => baseDefinition.toUnit(definition.toUnit(value)),
                        fromUnit: (value: any) => definition.fromUnit(baseDefinition.fromUnit(value)),
                    };
                    return;
                }
            });
        }
        return newDefinition;
    }

    createDefinition(targetUnit: Unit): UnitFunctionDefinition<any, any> {
        let newDefinition: UnitFunctionDefinition<any, any>;

        // Get base unit
        const baseUnitName = Unit.UNIT_BASES.get(this.baseName);
        const baseUnit = Unit.findByName(baseUnitName);

        if (this._definitions.has(targetUnit.name)) {
            // Direct conversion
            const definition = this._definitions.get(targetUnit.name);
            newDefinition = definition;
        } else if (targetUnit._definitions.has(this.name)) {
            // Reverse conversion
            const definition = targetUnit._definitions.get(this.name);
            newDefinition = {
                inputType: definition.inputType,
                outputType: definition.outputType,
                unit: targetUnit.name,
                toUnit: definition.fromUnit,
                fromUnit: definition.toUnit,
            };
            this._definitions.set(targetUnit.name, newDefinition);
        } else if (baseUnit.name !== this.name) {
            // No direct conversion found, convert to base unit
            const currentToBase = this._definitions.get(baseUnitName);
            const baseToTarget = baseUnit.createDefinition(targetUnit);

            // Convert unit if definitions are found
            if (currentToBase && baseToTarget) {
                newDefinition = {
                    inputType: currentToBase.inputType,
                    outputType: currentToBase.outputType,
                    unit: targetUnit.name,
                    toUnit: (value: UnitValueType) => baseToTarget.toUnit(currentToBase.toUnit(value)),
                    fromUnit: (value: UnitValueType) => currentToBase.fromUnit(baseToTarget.fromUnit(value)),
                };
                this._definitions.set(targetUnit.name, newDefinition);
            }
        }
        return newDefinition;
    }

    /**
     * Get the unit specifier
     * @param {UnitPrefix} prefix Unit prefix
     * @returns {Unit} Unit with specifier
     */
    specifier(prefix: UnitPrefix): this {
        // Check if the unit already exists
        const unitName = `${prefix.name}${this.name}`;
        if (Unit.UNITS.has(unitName)) {
            return Unit.UNITS.get(unitName) as this;
        }

        // Confirm that the prefix is allowed
        if (!this.prefixes.includes(prefix)) throw new Error(`Prefix '${prefix.name}' is not allowed for this unit!`);

        // Get the unit constructor of the extended class. This allows
        // serializing of units that are extended (e.g. LengthUnit)
        const UnitConstructor = Object.getPrototypeOf(this).constructor;
        const unit: Unit = new UnitConstructor();
        unit._name = unitName;
        unit._baseName = this.baseName;
        const aliases: Array<string> = [];
        this.aliases.forEach((alias) => {
            aliases.push(`${prefix.name}${alias}`);
            aliases.push(`${prefix.abbrevation}${alias}`);
        });
        unit._aliases = aliases;
        unit._definitions.set(this.name, {
            unit: this.name,
            toUnit: (value: number) => value * prefix.magnitude,
            fromUnit: (value: number) => value / prefix.magnitude,
        });
        return Unit.registerUnit(unit) as this;
    }

    /**
     * Find unit specifier by name or alias
     * @param {string} name Unit name
     * @returns {Unit | undefined} Unit if found
     */
    protected findByName(name: string): Unit | undefined {
        // Check all aliases in those units
        for (const alias of this.aliases.concat(this.name)) {
            if (name === alias) {
                // Exact match with alias
                return this;
            } else if (name.endsWith(alias)) {
                // Unit that we are looking for ends with the alias
                // confirm that there is a prefix match
                for (const prefix of this.prefixes) {
                    if (name.match(prefix.abbrevationPattern) || name.match(prefix.namePattern)) {
                        return this.specifier(prefix);
                    }
                }
            }
        }
        return undefined;
    }

    /**
     * Find a unit by its name
     * @param {string} name Unit name
     * @param {string} baseName Optional base name to specific result
     * @returns {Unit | undefined} Unit if found
     */
    static findByName(name: string, baseName?: string): Unit | undefined {
        if (name === undefined) {
            return undefined;
        } else if (Unit.UNITS.has(name)) {
            return Unit.UNITS.get(name);
        } else {
            // Check all units
            for (const [, unit] of Unit.UNITS) {
                if (baseName ? baseName !== unit.baseName : false) {
                    continue;
                }
                // Check all aliases in those units
                const result = unit.findByName(name);
                if (result) {
                    return result;
                }
            }
            return undefined;
        }
    }

    /**
     * Convert a value in the current unit to a target unit
     * @param {UnitValueType} value Value to convert
     * @param {string | Unit} target Target unit
     * @returns {number} Converted unit
     */
    convert<T extends UnitValueType>(value: T, target: string | Unit): T {
        const targetUnit: Unit = target instanceof Unit ? target : Unit.findByName(target, this.baseName);

        // Do not convert if target unit is the same or undefined
        if (!targetUnit || targetUnit.name === this.name) {
            return value;
        }

        const definition = this.createDefinition(targetUnit);
        if (!definition) {
            throw new Error(`No conversion definition found from '${this.name}' to '${targetUnit.name}'!`);
        } else {
            if (value instanceof Vector3 && definition.inputType !== Vector3) {
                // Convert vector individually when definition only supports atomic data
                return value
                    .clone()
                    .fromArray([
                        definition.toUnit(value.x),
                        definition.toUnit(value.y),
                        definition.toUnit(value.z),
                    ]) as T;
            } else {
                return definition.toUnit(value) as T;
            }
        }
    }

    /**
     * Convert a value from a specific unit to a target unit
     * @param {UnitValueType} value Value to convert
     * @param {string | Unit} from Source unit
     * @param {string | Unit} to Target unit
     * @returns {UnitValueType} Converted unit
     */
    static convert<T extends UnitValueType>(value: T, from: string | Unit, to: string | Unit): T {
        const fromUnit: Unit = typeof from === 'string' ? Unit.findByName(from) : from;
        return fromUnit.convert(value, to);
    }

    /**
     * Register a new unit
     * @param {Unit} unit Unit to register
     * @param {boolean} override Override an existing unit with the same name
     * @returns {Unit} Registered unit
     */
    static registerUnit(unit: Unit, override: boolean = false): Unit {
        if (!unit.name) {
            return unit;
        }

        // Register unit if it does not exist yet
        if (!Unit.UNITS.has(unit.name) || override) {
            Unit.UNITS.set(unit.name, unit);
        }
        // Check if the unit is a new base unit
        const baseName = unit.baseName ? unit.baseName : unit.name;
        const baseUnitName = Unit.UNIT_BASES.get(baseName);
        if (!baseUnitName) {
            Unit.UNIT_BASES.set(baseName, unit.name);
        } else {
            // Confirm that the unit can be converted to a base unit
            const baseUnit = Unit.findByName(baseUnitName, baseName);
            const fromBase = baseUnit.createDefinition(unit);
            const toBase = unit.createBaseDefinition();
            if (!fromBase) {
                // No conversion definition
                unit._definitions.set(baseUnitName, toBase);
            }
        }
        return unit;
    }
}