Difference between revisions of "MediaWiki:Gadget-calculator-core.js"

From WikiAnesthesia
Line 912: Line 912:


             var isTable = this.tagName.toLowerCase() === 'tr';
             var isTable = this.tagName.toLowerCase() === 'tr';
            var $infoButton = null;
            if( calculation.hasInfo() ) {
                $infoButton = $( '<a>', {
                    'data-toggle': 'collapse',
                    href: '#collapseInfo',
                    role: 'button',
                    'aria-expanded': 'false',
                    'aria-controls': calculation.getContainerClass() + '-info'
                } )
                    .append( $( '<i>', {
                        class: 'far fa-question-circle'
                    } ) );
            }


             if( isTable ) {
             if( isTable ) {
Line 924: Line 939:


                 if( calculation.hasInfo() ) {
                 if( calculation.hasInfo() ) {
                    var $infoButton = $( '<i>', {
                        class: 'far fa-question-circle'
                    } );
                     $( this )
                     $( this )
                         .append( $( '<td>', {
                         .append( $( '<td>', {
Line 935: Line 946:
             } else {
             } else {
                 $( this )
                 $( this )
                     .append( calculation.getLabelHtml() + ': ' + valueString );
                     .append( calculation.getLabelHtml() + $infoButton + ': ' + valueString );
             }
             }



Revision as of 23:43, 31 July 2021

/**
 * @author Chris Rishel
 */
( function() {
    const COOKIE_EXPIRATION = 12 * 60 * 60;

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

    const VALID_TYPES = [
        TYPE_NUMBER,
        TYPE_STRING
    ];

    // Polyfill to fetch unit's base. This may become unnecessary in a future version of math.js
    math.Unit.prototype.getBase = function() {
        for( var iBase in math.Unit.BASE_UNITS ) {
            if( this.equalBase( math.Unit.BASE_UNITS[ iBase ] ) ) {
                return iBase;
            }
        }

        return null;
    };


    mw.calculators = {
        calculators: {},
        calculations: {},
        objectClasses: {},
        units: {},
        unitsBases: {},
        variables: {},
        addCalculations: function( calculationData ) {
            var calculations = mw.calculators.createCalculatorObjects( 'CalculatorCalculation', calculationData );

            for( var calculationId in calculations ) {
                var calculation = calculations[ calculationId ];

                mw.calculators.calculations[ calculationId ] = calculation;

                var inputCalculations = calculation.data.calculations.required.concat( calculation.data.calculations.optional );

                for( var iInputCalculation in inputCalculations ) {
                    var inputCalculationId = inputCalculations[ iInputCalculation ];

                    if( !mw.calculators.calculations.hasOwnProperty( inputCalculationId ) ) {
                        throw new Error('Calculation "' + inputCalculationId + '" does not exist for calculation "' + calculationId + '"');
                    }

                    mw.calculators.calculations[ inputCalculationId ].addCalculation( calculationId );
                }

                var inputVariables = calculation.data.variables.required.concat( calculation.data.variables.optional );

                for( var iInputVariable in inputVariables ) {
                    var inputVariableId = inputVariables[ iInputVariable ];

                    if( !mw.calculators.variables.hasOwnProperty( inputVariableId ) ) {
                        throw new Error('Variable "' + inputVariableId + '" does not exist for calculation "' + calculationId + '"');
                    }

                    mw.calculators.variables[ inputVariableId ].addCalculation( calculationId );
                }
            }
        },
        addCalculators: function( moduleId, calculatorData ) {
            var calculators = mw.calculators.createCalculatorObjects( 'CalculatorCalculator', calculatorData );

            if( !mw.calculators.calculators.hasOwnProperty( moduleId ) ) {
                mw.calculators.calculators[ moduleId ] = {};
            }

            for( var calculatorId in calculators ) {
                mw.calculators.calculators[ moduleId ][ calculatorId ] = calculators[ calculatorId ];

                mw.calculators.calculators[ moduleId ][ calculatorId ].render();
            }
        },
        addUnitsBases: function( unitsBaseData ) {
            var unitsBases = mw.calculators.createCalculatorObjects( 'CalculatorUnitsBase', unitsBaseData );

            for( var unitsBaseId in unitsBases ) {
                mw.calculators.unitsBases[ unitsBaseId ] = unitsBases[ unitsBaseId ];
            }
        },
        addUnits: function( unitsData ) {
            var units = mw.calculators.createCalculatorObjects( 'CalculatorUnits', unitsData );

            for( var unitsId in units ) {
                if( mw.calculators.units.hasOwnProperty( unitsId ) ) {
                    continue;
                }

                // CalculatorUnits class variables directly support the options object format for math.createUnit
                math.createUnit( unitsId, {
                    aliases: units[ unitsId ].aliases,
                    baseName: units[ unitsId ].baseName,
                    definition: units[ unitsId ].definition,
                    prefixes: units[ unitsId ].prefixes,
                    offset: units[ unitsId ].offset,
                } );

                mw.calculators.units[ units ] = units[ unitsId ];
            }
        },
        addVariables: function( variableData ) {
            var variables = mw.calculators.createCalculatorObjects( 'CalculatorVariable', variableData );

            for( var varId in variables ) {
                var variable = variables[ varId ];

                var cookieValue = mw.calculators.getCookieValue( varId );

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

                mw.calculators.variables[ varId ] = variable;
            }
        },
        createCalculatorObjects: function( className, objectData ) {
            if( !mw.calculators.objectClasses.hasOwnProperty( className ) ) {
                throw new Error( 'Invalid class name "' + className + '"' );
            }

            var objects = {};

            for( var objectId in objectData ) {
                var propertyValues = objectData[ objectId ];

                propertyValues.id = objectId;

                objects[ objectId ] = new mw.calculators.objectClasses[ className ]( propertyValues );
            }

            return objects;
        },
        createInputGroup: function( variableIds ) {
            var $form = $( '<form>', {

            } );

            var $formRow = $( '<div>', {
                class: 'form-row'
            } ).css( 'flex-wrap', 'nowrap' );

            for( var iVariableId in variableIds ) {
                var variableId = variableIds[ iVariableId ];

                if( !mw.calculators.variables.hasOwnProperty( variableId ) ) {
                    throw new Error( 'Invalid variable name "' + variableId + '"' );
                }

                $formRow.append( mw.calculators.variables[ variableId ].createInput() );
            }

            return $form.append( $formRow );
        },
        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( calculationId ) {
            if( mw.calculators.calculations.hasOwnProperty( calculationId ) ) {
                return mw.calculators.calculations[ calculationId ];
            } else {
                return null;
            }
        },
        getCalculator: function( moduleId, calculatorId ) {
            if( mw.calculators.calculators.hasOwnProperty( moduleId ) &&
                mw.calculators.calculators[ moduleId ].hasOwnProperty( calculatorId ) ) {
                return mw.calculators.calculators[ moduleId ][ calculatorId ];
            } else {
                return null;
            }
        },
        getUnitsString: function( value ) {
            if( typeof value !== 'object' ) {
                return null;
            }

            var units = value.formatUnits().replace( /\s/g, '' );

            var unitsBase = value.getBase();

            if( mw.calculators.unitsBases.hasOwnProperty( unitsBase ) &&
                typeof mw.calculators.unitsBases[ unitsBase ].toString === 'function' ) {
                units = mw.calculators.unitsBases[ unitsBase ].toString( units );
            }

            return units;
        },
        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() {
            $( '.calculator' ).each( function() {
                var gadgetModule = 'ext.gadget.calculator-' + $( this ).attr( 'data-module' );

                if( gadgetModule && mw.loader.getState( gadgetModule ) === 'registered' ) {
                    mw.loader.load( gadgetModule );
                }
            } );
        },
        isMobile: function() {
            return window.matchMedia( 'only screen and (max-width: 760px)' ).matches;
        },
        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;
        }
    };

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

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

                        return null;
                    }

                    this[ requiredProperty ] = propertyValues[ requiredProperty ];

                    delete propertyValues[ requiredProperty ];
                }
            }

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

                    if( propertyValues && propertyValues.hasOwnProperty( optionalProperty ) ) {
                        this[ optionalProperty ] = propertyValues[ optionalProperty ];

                        delete propertyValues[ optionalProperty ];
                    } else if( typeof this[ optionalProperty ] === 'undefined' ) {
                        this[ optionalProperty ] = null;
                    }
                }
            }

            var invalidProperties = Object.keys( propertyValues );

            if( invalidProperties.length ) {
                console.warn( 'Unsupported properties defined for ' + typeof this + ' with id "' + this.id + '": ' + invalidProperties.join( ', ' ) );
            }
        }
    };




    /**
     * Class CalculatorUnitsBase
     * @param {Object} propertyValues
     * @returns {mw.calculators.objectClasses.CalculatorUnitsBase}
     * @constructor
     */
    mw.calculators.objectClasses.CalculatorUnitsBase = function( propertyValues ) {
        var properties = {
            required: [
                'id'
            ],
            optional: [
                'toString'
            ]
        };

        mw.calculators.objectClasses.CalculatorObject.call( this, properties, propertyValues );
    };

    mw.calculators.objectClasses.CalculatorUnitsBase.prototype = Object.create( mw.calculators.objectClasses.CalculatorObject.prototype );




    /**
     * Class CalculatorUnits
     * @param {Object} propertyValues
     * @returns {mw.calculators.objectClasses.CalculatorUnits}
     * @constructor
     */
    mw.calculators.objectClasses.CalculatorUnits = function( propertyValues ) {
        var properties = {
            required: [
                'id'
            ],
            optional: [
                'aliases',
                'baseName',
                'definition',
                'offset',
                'prefixes'
            ]
        };

        mw.calculators.objectClasses.CalculatorObject.call( this, properties, propertyValues );
    };

    mw.calculators.objectClasses.CalculatorUnits.prototype = Object.create( mw.calculators.objectClasses.CalculatorObject.prototype );




    /**
     * Class CalculatorVariable
     * @param {Object} propertyValues
     * @returns {mw.calculators.objectClasses.CalculatorVariable}
     * @constructor
     */
    mw.calculators.objectClasses.CalculatorVariable = function( propertyValues ) {
        var properties = {
            required: [
                'id',
                'name',
                'type'
            ],
            optional: [
                'abbreviation',
                'defaultValue',
                'maxLength',
                'options',
                'units'
            ]
        };

        mw.calculators.objectClasses.CalculatorObject.call( this, properties, propertyValues );

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

        this.calculations = [];

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

    mw.calculators.objectClasses.CalculatorVariable.prototype = Object.create( mw.calculators.objectClasses.CalculatorObject.prototype );

    mw.calculators.objectClasses.CalculatorVariable.prototype.addCalculation = function( calculationId ) {
        if( this.calculations.indexOf( calculationId ) !== -1 ) {
            return;
        }

        this.calculations.push( calculationId );
    };

    mw.calculators.objectClasses.CalculatorVariable.prototype.createInput = function() {
        var varId = this.getId();
        var value = this.getValue();

        var inputContainerAttribs = {
            class: 'form-group calculator-container-input'
        };

        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;

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

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

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

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

            // Configure additional options
            if( this.maxLength ) {
                inputAttributes.maxlength = this.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'
                };

                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: mw.calculators.getUnitsString( math.unit( '0 ' + units ) ),
                        value: 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 group
            var $inputGroup = $( '<div>', {
                class: 'input-group'
            } ).append( $input );

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

            $inputContainer.append( $inputGroup );
        } else if( this.type === TYPE_STRING ) {
            if( this.hasOptions() ) {
                var varOptions = this.getOptions();

                var selectAttributes = {
                    id: inputId,
                    class: 'custom-select calculator-input-select'
                };

                var $select = $( '<select>', selectAttributes )
                    .on( 'change', function() {
                        mw.calculators.setValue( varId, $( this ).val() );
                    } );

                for( var iVarOption in varOptions ) {
                    var varOption = varOptions[ iVarOption ];

                    var optionAttributes = {
                        value: varOption,
                        text: varOption
                    };

                    if( varOption === value ) {
                        optionAttributes.selected = true;
                    }

                    $select.append( $( '<option>', optionAttributes ) );
                }

                $inputContainer.append( $select );
            }
        }

        return $inputContainer;
    };

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

    mw.calculators.objectClasses.CalculatorVariable.prototype.getLabelString = function() {
        return mw.calculators.isMobile() && this.abbreviation ? this.abbreviation : this.name;
    };

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

    mw.calculators.objectClasses.CalculatorVariable.prototype.getOptions = function() {
        return this.options;
    };

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

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

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

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

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

        return true;
    };

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

    mw.calculators.objectClasses.CalculatorVariable.prototype.setValue = function( value ) {
        if( this.type === TYPE_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;

        for( var iCalculation in this.calculations ) {
            var calculation = mw.calculators.getCalculation( this.calculations[ iCalculation ] );

            if( calculation ) {
                calculation.render();
            }
        }

        return true;
    };



    /**
     * Class CalculatorCalculation
     * @param {Object} propertyValues
     * @returns {mw.calculators.objectClasses.CalculatorCalculation}
     * @constructor
     */
    mw.calculators.objectClasses.CalculatorCalculation = function( propertyValues ) {
        var properties = {
            required: [
                'id',
                'name',
                'calculate'
            ],
            optional: [
                'abbreviation',
                'data',
                'digits',
                'formula',
                'link',
                'references',
                'onRender',
                'onRendered',
                'type',
                'units'
            ]
        };

        mw.calculators.objectClasses.CalculatorObject.call( this, properties, propertyValues );

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

        // Initialize array to store calculation ids which depend on this calculation's value
        this.calculations = [];

        this.type = this.type ? this.type : TYPE_NUMBER;

        dataPrototype = {
            optional: [],
            required: []
        };

        this.data = this.data ? this.data : {
            calculations: dataPrototype,
            variables: dataPrototype
        };

        if( !this.data.hasOwnProperty( 'calculations' ) ) {
            this.data.calculations = dataPrototype;
        } else {
            this.data.calculations.optional = this.data.calculations.hasOwnProperty( 'optional' ) ? this.data.calculations.optional : [];
            this.data.calculations.required = this.data.calculations.hasOwnProperty( 'required' ) ? this.data.calculations.required : [];
        }

        if( !this.data.hasOwnProperty( 'variables' ) ) {
            this.data.variables = dataPrototype;
        } else {
            this.data.variables.optional = this.data.variables.hasOwnProperty( 'optional' ) ? this.data.variables.optional : [];
            this.data.variables.required = this.data.variables.hasOwnProperty( 'required' ) ? this.data.variables.required : [];
        }

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

    mw.calculators.objectClasses.CalculatorCalculation.prototype = Object.create( mw.calculators.objectClasses.CalculatorObject.prototype );

    mw.calculators.objectClasses.CalculatorCalculation.prototype.addCalculation = function( calculationId ) {
        if( this.calculations.indexOf( calculationId ) !== -1 ) {
            return;
        }

        this.calculations.push( calculationId );
    };

    mw.calculators.objectClasses.CalculatorCalculation.prototype.getId = function() {
        return this.id;
    };

    mw.calculators.objectClasses.CalculatorCalculation.prototype.getContainerClass = function() {
        return 'calculator-calculation-' + this.id;
    };

    mw.calculators.objectClasses.CalculatorCalculation.prototype.getLabelHtml = function() {
        var labelHtml = this.getLabelString();

        if( this.link ) {
            var href = this.link;

            // Detect internal links (this isn't great)
            var matches = href.match(/\[\[(.*?)\]\]/);

            if( matches ) {
                href = mw.util.getUrl( matches[ 1 ] );
            }

            labelHtml = $( '<a>', {
                href: href,
                text: labelHtml
            } )[ 0 ].outerHTML;
        }

        return labelHtml;
    };

    mw.calculators.objectClasses.CalculatorCalculation.prototype.getLabelString = function() {
        return mw.calculators.isMobile() && this.abbreviation ? this.abbreviation : this.name;
    };

    mw.calculators.objectClasses.CalculatorCalculation.prototype.getValueString = function() {
        if( this.message ) {
            return this.message;
        } else if( typeof this.value === 'object' && this.value.hasOwnProperty( 'value' ) ) {
            var value, units;

            if( this.units ) {
                value = this.value.toNumber( this.units );

                units = mw.calculators.getUnitsString( this.value );
            } else {
                value = this.value.toNumber();
            }

            var digits = this.digits !== null ? this.digits : 1;

            value = value.toFixed( digits );

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

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

    mw.calculators.objectClasses.CalculatorCalculation.prototype.hasInfo = function() {
        return this.formula || this.references.length;
    };

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

        return true;
    };

    mw.calculators.objectClasses.CalculatorCalculation.prototype.isValueMathObject = function() {
        return this.value && this.value.hasOwnProperty( 'value' );
    };

    mw.calculators.objectClasses.CalculatorCalculation.prototype.recalculate = function() {
        this.message = '';

        var data = {};
        var missingRequiredData = '';
        var calculationId, calculation, varId, variable;

        for( var iRequiredCalculation in this.data.calculations.required ) {
            calculationId = this.data.calculations.required[ iRequiredCalculation ];
            calculation = mw.calculators.getCalculation( calculationId );

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

                missingRequiredData = missingRequiredData + calculation.getLabelString();
            } else {
                data[ calculationId ] = calculation.value;
            }
        }

        for( var iRequiredVariable in this.data.variables.required ) {
            varId = this.data.variables.required[ iRequiredVariable ];
            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 + variable.getLabelString();
            } else {
                data[ varId ] = variable.getValue();
            }
        }

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

            return false;
        }

        for( var iOptionalVariable in this.data.variables.optional ) {
            varId = this.data.variables.optional[ iOptionalVariable ];
            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;
        }

        for( var iOptionalCalculation in this.data.calculations.optional ) {
            calculationId = this.data.calculations.optional[ calculationId ];
            calculation = mw.calculators.getVariable( calculationId );

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

            data[ calculationId ] = calculation.hasValue() ? calculation.getValue() : null;
        }


        try {
            var value = this.calculate( data );

            if( this.type === TYPE_NUMBER && !isNaN( value ) ) {
                if( this.units ) {
                    value = value + ' ' + this.units;
                }

                this.value = math.unit( value );
            } else {
                this.value = value;
            }

            for( var iCalculation in this.calculations ) {
                var calculation = mw.calculators.getCalculation( this.calculations[ iCalculation ] );

                if( calculation ) {
                    calculation.render();
                }
            }
        } catch( e ) {
            this.message = e.message;
            this.value = null;
        }

        return true;
    };

    mw.calculators.objectClasses.CalculatorCalculation.prototype.render = function() {
        var $calculationContainer = $( '.' + this.getContainerClass() );

        if( !$calculationContainer.length ) {
            return;
        }

        this.recalculate();

        if( typeof this.onRender === 'function' ) {
            this.onRender( $calculationContainer );
        }

        var valueString = this.getValueString();

        var inputVariableIds = this.data.variables.required.concat( this.data.variables.optional );
        var missingVariableInputs = [];

        for( var iInputVariableId in inputVariableIds ) {
            var variableId = inputVariableIds[ iInputVariableId ];

            if( !$( '#calculator-input-' + variableId ).length ) {
                missingVariableInputs.push( variableId );
            }
        }

        var calculation = this;

        $calculationContainer.each( function() {
            $( this ).empty();

            var isTable = this.tagName.toLowerCase() === 'tr';

            var $infoButton = null;

            if( calculation.hasInfo() ) {
                $infoButton = $( '<a>', {
                    'data-toggle': 'collapse',
                    href: '#collapseInfo',
                    role: 'button',
                    'aria-expanded': 'false',
                    'aria-controls': calculation.getContainerClass() + '-info'
                } )
                    .append( $( '<i>', {
                        class: 'far fa-question-circle'
                    } ) );
            }

            if( isTable ) {
                $( this )
                    .append( $( '<th>', {
                        html: calculation.getLabelHtml()
                    } ) )
                    .append( $( '<td>', {
                        colspan: calculation.hasInfo() ? 1 : 2,
                        html: valueString
                    } ) );

                if( calculation.hasInfo() ) {
                    $( this )
                        .append( $( '<td>', {
                            class: 'calculator-calculation-column-info'
                        } ).append( $infoButton ) );
                }
            } else {
                $( this )
                    .append( calculation.getLabelHtml() + $infoButton + ': ' + valueString );
            }

            if( missingVariableInputs.length ) {
                var variablesContainerClass = 'calculator-calculation-variables ' + calculation.getContainerClass() + '-variables';
                var inputGroup = mw.calculators.createInputGroup( missingVariableInputs );

                if( isTable ) {
                    $variablesContainer =  $( '<tr>' )
                        .append( $( '<td>', {
                            class: variablesContainerClass,
                            colspan: calculation.hasInfo() ? 3 : 2
                        } ).append( inputGroup ) );
                } else {
                    $variablesContainer = $( '<div>', {
                        class: variablesContainerClass
                    } ).append( inputGroup );
                }

                $( this ).after( $variablesContainer );

                missingVariableInputs = [];
            }

            // tr class=variables
            // tr class=info
        } );
return;





        if( missingVariableInputs.length ) {
            var $variableInputs = $( '<div>', {
                class: 'calculator-calculation-variables ' + this.getContainerClass() + '-variables'
            } );

            $variableInputs.append( mw.calculators.createInputGroup( missingVariableInputs ) );

            $calculationContainer.after( $variableInputs );
        }

        this.renderLabel();
        this.renderInfo();

        if( typeof this.onRendered === 'function' ) {
            this.onRendered( $calculationContainer );
        }
    };

    mw.calculators.objectClasses.CalculatorCalculation.prototype.renderInfo = function() {
        var $infoContainer = $( '.' + this.getContainerClass() + '-info' );

        if( !$infoContainer.length ) {
            return;
        }

        var infoHtml = '';

        if( this.references.length ) {
            var $references = $( '<ol>' );

            for( var iReference in this.references ) {
                $references.append( $( '<li>', {
                    text: this.references[ iReference ]
                } ) );
            }

            infoHtml = infoHtml + $references[ 0 ].outerHTML;
        }

        var $info = $( '<a> ', {
            'data-toggle': 'tooltip',
            'data-html': true,
            title: infoHtml
        } );

        $info.append( $( '<i>', {
            class: 'far fa-question-circle'
        } ) );

        $infoContainer.empty().append( $info );
    };





    /**
     * Class CalculatorCalculator
     * @param {Object} propertyValues
     * @returns {mw.calculators.objectClasses.CalculatorCalculator}
     * @constructor
     */
    mw.calculators.objectClasses.CalculatorCalculator = function( propertyValues ) {
        var properties = {
            required: [
                'id',
                'name',
                'calculations'
            ],
            optional: [
                'css',
                'onRender',
                'onRendered',
                'table'
            ]
        };

        mw.calculators.objectClasses.CalculatorObject.call( this, properties, propertyValues );
    };

    mw.calculators.objectClasses.CalculatorCalculator.prototype = Object.create( mw.calculators.objectClasses.CalculatorObject.prototype );

    mw.calculators.objectClasses.CalculatorCalculator.prototype.getContainerClass = function() {
        return 'calculator-' + this.id;
    };

    mw.calculators.objectClasses.CalculatorCalculator.prototype.render = function() {
        var $calculatorContainer = $( '.' + this.getContainerClass() );

        if( !$calculatorContainer.length ) {
            return;
        }

        if( typeof this.onRender === 'function' ) {
            this.onRender( $calculatorContainer );
        }

        if( this.css ) {
            $calculatorContainer.css( this.css );
        }

        $calculatorContainer.empty();

        $calculatorContainer.append( $( '<h4>', {
            text: this.name
        } ) );

        var $calculationsContainer;

        if( this.table ) {
            $calculationsContainer = $( '<table>', {
                class: 'wikitable'
            } ).append( '<tbody>' );
        } else {
            $calculationsContainer = $( '<div>' );
        }

        $calculatorContainer.append( $calculationsContainer );

        for( var iCalculationId in this.calculations ) {
            var calculation = mw.calculators.getCalculation( this.calculations[ iCalculationId ] );
            var calculationContainerClass = calculation.getContainerClass();

            var $calculationContainer = $( '.' + calculationContainerClass );

            // If a container doesn't exist yet, add it
            if( !$calculationContainer.length ) {
                if( this.table ) {
                    $calculationContainer = $( '<tr>', {
                        class: calculationContainerClass
                    } );
                } else {
                    $calculationContainer = $( '<div>', {
                        class: calculationContainerClass
                    } );
                }

                $calculationsContainer.append( $calculationContainer );
            }

            calculation.render();
        }

        $( '[data-toggle="tooltip"]' ).tooltip();

        if( typeof this.onRendered === 'function' ) {
            this.onRendered( $calculatorContainer );
        }
    };

    mw.calculators.init();

}() );