MediaWiki:Gadget-calculator-core.js

From WikiAnesthesia
Revision as of 06:03, 20 July 2021 by Chris Rishel (talk | contribs)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/**
 * @author Chris Rishel
 */
( function() {
    const COOKIE_EXPIRATION = 12 * 60 * 60;

    const INPUTSIZE_FULL = 'full';
    const INPUTSIZE_COMPACT = 'compact';

    const VALID_INPUTSIZES = [
        INPUTSIZE_FULL,
        INPUTSIZE_COMPACT
    ];

    const TYPE_NUMBER = 'number';
    const TYPE_STRING = 'string';

    const VALID_TYPES = [
        TYPE_NUMBER,
        TYPE_STRING
    ];

    /**
     * Class CalculatorObject
     *
     * @param {Object} varData
     * @param {Object} propertyData
     * @returns {Object}
     * @constructor
     */
    function CalculatorObject( varData, propertyData ) {
        if( propertyData ) {
            if( propertyData.hasOwnProperty( 'required' ) ) {
                for( var iRequiredProperty in propertyData.required ) {
                    var requiredProperty = propertyData.required[ iRequiredProperty ];

                    if( !varData.hasOwnProperty( requiredProperty ) ) {
                        console.error( 'Missing required property "' + requiredProperty + '"' );
                        console.log( varData );

                        return null;
                    }

                    this[ requiredProperty ] = varData[ requiredProperty ];
                }
            }

            if( propertyData.hasOwnProperty( 'optional' ) ) {
                for( var iOptionalProperty in propertyData.optional ) {
                    var optionalProperty = propertyData.optional[ iOptionalProperty ];

                    if( varData.hasOwnProperty( optionalProperty ) ) {
                        this[ optionalProperty ] = varData[ optionalProperty ];
                    } else {
                        this[ optionalProperty ] = null;
                    }
                }
            }
        }
    }


    /**
     * Class CalculatorVariable
     * @param {Object} varData
     * @returns {CalculatorVariable}
     * @constructor
     */
    function CalculatorVariable( varData ) {
        var propertyData = {
            required: [
                'id',
                'name',
                'type'
            ],
            optional: [
                'abbreviation',
                'defaultValue',
                'maxLength',
                'options',
                'renderUnits',
                'units'
            ]
        };

        CalculatorObject.call( this, varData, propertyData );

        if( VALID_TYPES.indexOf( this.type ) === -1 ) {
            throw new Error( 'Invalid type "' + this.type + '" for variable "' + this.id + '"' );
        }

        if( this.defaultValue ) {
            this.setValue( this.defaultValue );
        } else {
            this.value = null;
        }
    }

    CalculatorVariable.prototype = Object.create( CalculatorObject.prototype );

    CalculatorVariable.prototype.createInput = function( options ) {
        // Initialize options
        options = ( typeof options !== 'undefined' ) ?  options : {};

        if( !options.hasOwnProperty( 'size' ) ) {
            options.size = 'full';
        } else if( VALID_INPUTSIZES.indexOf( options.size ) === -1 ) {
            throw new Error( 'Invalid input size "' + options.size + '" for variable "' + this.getId() + '"' );
        }

        var varId = this.getId();
        var value = this.getValue();

        var inputContainerAttribs = {
            class: 'col-auto calculator-container-input'
        };

        if( options.size === INPUTSIZE_COMPACT ) {
            inputContainerAttribs.class = inputContainerAttribs.class + ' calculator-container-input-compact';
        }

        inputContainerAttribs.class = inputContainerAttribs.class + ' calculator-container-input-' + this.getName();

        // Create the input container
        var $inputContainer = $( '<div>', inputContainerAttribs );

        // Set the input id
        var inputId = 'calculator-input-' + varId;

        if( this.getType() === TYPE_NUMBER ) {
            // Initialize the primary units variables (needed for handlers, even if doesn't have units)
            var unitsId = null;
            var $unitsContainer = null;

            // Initialize label attributes
            var labelAttributes = {
                for: inputId,
                text: this.getName()
            };

            // Initialize input options
            var inputAttributes = {
                id: inputId,
                class: 'form-control calculator-input-text',
                type: 'text',
                inputmode: 'decimal',
                placeholder: this.getName(),
                value: this.isValueMathObject() ? value.toNumber() : value
            };

            // Configure additional options
            if( this.maxLength ) {
                inputAttributes.maxlength = this.maxLength;
            }

            if( options.size === INPUTSIZE_COMPACT ) {
                // Only use label for screen readers
                labelAttributes.class = 'sr-only';

                inputAttributes.class = inputAttributes.class + ' calculator-input-text-compact';

                // Switch the placeholder to the abbreviation if defined
                if( this.abbreviation ) {
                    inputAttributes.placeholder = this.abbreviation;
                }

                // Set the size of the input to the maxlength if defined
                if( inputAttributes.hasOwnProperty( 'maxlength' ) ) {
                    inputAttributes.size = inputAttributes.maxlength;
                }
            }

            // Add the input id to the list of classes
            inputAttributes.class = inputAttributes.class + ' ' + inputId;

            // If the variable has units, create the units input
            if( this.hasUnits() ) {
                // Set the units id
                unitsId = inputId + '-units';

                var unitsValue = this.isValueMathObject() ? value.formatUnits() : null;

                // Create the units container
                $unitsContainer = $( '<div>', {
                    class: 'input-group-append'
                } );

                // Initialize the units input options
                var unitsInputAttributes = {
                    id: unitsId,
                    class: 'custom-select calculator-input-select'
                };

                if( options.size === INPUTSIZE_COMPACT ) {
                    unitsInputAttributes.class = unitsInputAttributes.class + ' calculator-input-select-compact';
                }

                unitsInputAttributes.class = unitsInputAttributes.class + ' ' + unitsId;

                var $unitsInput = $( '<select>', unitsInputAttributes )
                    .on( 'change', function() {
                        var newValue = $( '#' + inputId ).val() + ' ' + $( this ).val();

                        mw.calculators.setValue( varId, newValue );
                    } );

                for( var iUnits in this.units ) {
                    var units = this.units[ iUnits ];

                    var unitsOptionAttributes = {
                        text: units,
                        value: units
                    };

                    // Apply custom rendering function for units if defined
                    if( typeof this.renderUnits === 'function' ) {
                        unitsOptionAttributes.text = this.renderUnits( units );
                    }

                    if( units === unitsValue ) {
                        unitsOptionAttributes.selected = true;
                    }

                    $unitsInput.append( $( '<option>', unitsOptionAttributes ) );
                }

                $unitsContainer.append( $unitsInput );
            }

            // Create the input and add handlers
            var $input = $( '<input>', inputAttributes )
                .on( 'input', function() {
                    var newValue = $( this ).val();

                    if( unitsId ) {
                        newValue = newValue + ' ' + $( '#' + unitsId ).val();
                    }

                    mw.calculators.setValue( varId, newValue );
                } );

            // Create the input label and append to the container
            $inputContainer.append( $( '<label>', labelAttributes ) );

            // Create the input group
            var $inputGroup = $( '<div>', {
                class: 'input-group'
            } ).append( $input );

            if( $unitsContainer ) {
                $inputGroup.append( $unitsContainer );
            }

            $inputContainer.append( $inputGroup );
        } else if( this.getType === TYPE_STRING ) {

        }

        return $inputContainer;
    };

    CalculatorVariable.prototype.getId = function() {
        return this.id;
    };

    CalculatorVariable.prototype.getName = function() {
        return this.name;
    };

    CalculatorVariable.prototype.getType = function() {
        return this.type;
    };

    CalculatorVariable.prototype.getValue = function() {
        return this.value;
    };

    CalculatorVariable.prototype.getValueString = function() {
        return String( this.value );
    };

    CalculatorVariable.prototype.hasOptions = function() {
        return this.options !== null;
    };

    CalculatorVariable.prototype.hasUnits = function() {
        return this.units !== null;
    };

    CalculatorVariable.prototype.hasValue = function() {
        if( !this.value ||
            ( this.isValueMathObject() && !this.value.toNumber() ) ) {
            return false;
        }

        return true;
    };

    CalculatorVariable.prototype.isValueMathObject = function() {
        return this.value && this.value.hasOwnProperty( 'value' );
    };

    CalculatorVariable.prototype.setValue = function( value ) {
        if( this.getType() === 'number' ) {
            if( typeof value !== 'object' ) {
                value = math.unit( value );
            }

            if( this.hasUnits() ) {
                var valueUnits = value.formatUnits();

                if( !valueUnits ) {
                    throw new Error( 'Could not set value for "' + this.getId() + '": Value must define units' );
                } else if( this.units.indexOf( valueUnits ) === -1 ) {
                    throw new Error( 'Could not set value for "' + this.getId() + '": Units "' + valueUnits + '" are not valid for this variable' );
                }
            }
        } else if( this.hasOptions() ) {
            if( this.options.indexOf( value ) === -1 ) {
                throw new Error( 'Could not set value "' + value + '" for "' + this.getId() + '": Value must define be one of: ' + this.options.join( ', ' ) );
            }
        }

        this.value = value;

        return true;
    };


    /**
     * Class CalculatorCalculation
     * @param {Object} varData
     * @returns {CalculatorCalculation}
     * @constructor
     */
    function CalculatorCalculation( varData ) {
        var propertyData = {
            required: [
                'id',
                'name',
                'calculate'
            ],
            optional: [
                'abbreviation',
                'digits',
                'requiredData',
                'optionalData',
                'references',
                'units'
            ]
        };

        CalculatorObject.call( this, varData, propertyData );

        if( typeof this.calculate !== 'function' ) {
            throw new Error( 'calculate() must be a function for Calculation "' + this.id + '"' );
        }

        this.requiredData = this.requiredData ? this.requiredData : [];
        this.optionalData = this.optionalData ? this.optionalData : [];

        this.message = null;
        this.value = null;
    }

    CalculatorCalculation.prototype = Object.create( CalculatorObject.prototype );

    CalculatorCalculation.prototype.recalculate = function() {
        var data = {};
        var missingRequiredData = '';
        var varId, variable;

        for( var iRequiredVar in this.requiredData ) {
            varId = this.requiredData[ iRequiredVar ];
            variable = mw.calculators.getVariable( varId );

            if( !variable ) {
                throw new Error( 'Invalid required variable "' + varId + '" for calculation "' + this.id + '"' );
            } else if( !variable.hasValue() ) {
                if( missingRequiredData ) {
                    missingRequiredData = missingRequiredData + ', ';
                }

                missingRequiredData = missingRequiredData + varId;
            } else {
                data[ varId ] = variable.getValue();
            }
        }

        if( missingRequiredData ) {
            this.message = 'Missing: ' + missingRequiredData;

            return false;
        }

        for( var iOptionalVar in this.optionalData ) {
            varId = this.optionalData[ iOptionalVar ];
            variable = mw.calculators.getVariable( varId );

            if( !variable ) {
                throw new Error( 'Invalid optional variable "' + varId + '" for calculation "' + this.id + '"' );
            }

            data[ varId ] = variable.hasValue() ? variable.getValue() : null;
        }

        var value = this.calculate( data );

        if( this.units ) {
            value = value + ' ' + this.units;
        }

        this.value = math.unit( value );

        return true;
    };

    CalculatorCalculation.prototype.render = function() {
        this.recalculate();

        return this.toString();
    };

    CalculatorCalculation.prototype.toString = function() {
        if( this.message ) {
            return this.message;
        } else if( typeof this.value === 'object' && this.value.hasOwnProperty( 'value' ) ) {
            var value = this.value.toNumber( this.units );

            if( this.digits !== null ) {
                value = value.toFixed( this.digits );
            }

            if( this.units ) {
                value = value + ' ' + this.value.formatUnits().replace( /\s/g, '' );
            }

            return value;
        } else {
            return String( this.value );
        }
    };


    mw.calculators = {
        calculations: {},
        variables: {},
        addCalculations: function( calculations ) {
            for( var calcId in calculations ) {
                if( mw.calculators.calculations.hasOwnProperty( calcId ) ) {
                    console.warn( 'Calculation variable "' + calcId + '" already defined.' );
                    continue;
                }

                var calcData = calculations[ calcId ];

                calcData.id = calcId;

                var calculation = new CalculatorCalculation( calcData );

                if( calculation ) {
                    mw.calculators.calculations[ calcId ] = calculation;
                }
            }
        },
        addVariables: function( variables ) {
            for( var varId in variables ) {
                if( mw.calculators.variables.hasOwnProperty( varId ) ) {
                    console.warn( 'Calculation variable "' + varId + '" already defined.' );
                    continue;
                }

                var varData = variables[ varId ];

                varData.id = varId;

                var variable = new CalculatorVariable( varData );

                if( variable ) {
                    var cookieValue = mw.calculators.getCookieValue( varId );

                    if( cookieValue ) {
                        variable.setValue( cookieValue );
                    }

                    mw.calculators.variables[ varId ] = variable;
                }
            }
        },
        defineUnits: function() {
            // Create aliases for abbreviations of existing units
            math.createUnit( 'dy', {
                definition: '1 day'
            } );

            math.createUnit( 'mo', {
                definition: '1 month'
            } );

            math.createUnit( 'yr', {
                definition: '1 year'
            } );

            // Body weight needs to be treated as a different fundamental unit type compared to mass for stoichiometry
            // Gram-weight, which uses short SI prefixes (e.g. 1 kgwt = 1000 gwt)
            math.createUnit( 'gwt', {
                prefixes: 'short'
            } );

            // Pound-weight, which uses no prefixes
            math.createUnit( 'lbwt', {
                definition: '453.59237 gwt'
            } );

            math.createUnit( 'puff' );

            math.createUnit( 'unit', {
                aliases: [ 'units' ]
            } );

            math.createUnit( 'vial', {
                aliases: [ 'vials' ]
            } );

        },
        getCookieKey: function( varId ) {
            return 'calculators-var-' + varId;
        },
        getCookieValue: function( varId ) {
            var cookieValue = mw.cookie.get( mw.calculators.getCookieKey( varId ) );

            if( !cookieValue ) {
                return null;
            }

            return cookieValue;
        },
        getCalculation: function( calcId ) {
            if( mw.calculators.calculations.hasOwnProperty( calcId ) ) {
                return mw.calculators.calculations[ calcId ];
            } else {
                return null;
            }
        },
        getVariable: function( varId ) {
            if( mw.calculators.variables.hasOwnProperty( varId ) ) {
                return mw.calculators.variables[ varId ];
            } else {
                return null;
            }
        },
        getValue: function( varId ) {
            if( mw.calculators.variables.hasOwnProperty( varId ) ) {
                return mw.calculators.variables[ varId ].getValue();
            } else {
                return null;
            }
        },
        init: function() {
            mw.calculators.defineUnits();
        },
        setValue: function( varId, value ) {
            if( !mw.calculators.variables.hasOwnProperty( varId ) ) {
                return false;
            }

            if( mw.calculators.variables[ varId ].setValue( value ) ) {
                mw.cookie.set( mw.calculators.getCookieKey( varId ), value, {
                    expires: COOKIE_EXPIRATION
                } );

                return true;
            }

            return false;
        }
    };

    mw.calculators.init();

}() );