Example #1
0
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var Dimension2 = require( 'DOT/Dimension2' );
  var inherit = require( 'PHET_CORE/inherit' );
  var LaserPointerNode = require( 'SCENERY_PHET/LaserPointerNode' );

  /**
   * @param {Light} light
   * @param {ModelViewTransform2} modelViewTransform
   * @param {Tandem} tandem
   * @constructor
   */
  function LightNode( light, modelViewTransform, tandem ) {

    LaserPointerNode.call( this, light.onProperty, {
      bodySize: new Dimension2( 126, 78 ),
      nozzleSize: new Dimension2( 16, 65 ),
      buttonRadius: 26,
      buttonTouchAreaDilation: 25,
      translation: modelViewTransform.modelToViewPosition( light.location ),
      tandem: tandem
    } );
  }

  beersLawLab.register( 'LightNode', LightNode );

  return inherit( LaserPointerNode, LightNode );
} );
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var inherit = require( 'PHET_CORE/inherit' );
  var PrecipitateParticleIO = require( 'BEERS_LAW_LAB/concentration/model/PrecipitateParticleIO' );
  var SoluteParticle = require( 'BEERS_LAW_LAB/concentration/model/SoluteParticle' );

  /**
   * @param {Solute} solute
   * @param {Vector2} location location in the beaker's coordinate frame
   * @param {number} orientation in radians
   * @param {Object} [options]
   * @constructor
   */
  function PrecipitateParticle( solute, location, orientation, options ) {

    options = _.extend( {
      phetioType: PrecipitateParticleIO
    }, options );

    SoluteParticle.call( this, solute.particleColor, solute.particleSize, location, orientation, options );

    // @public (phet-io)
    this.solute = solute;
  }

  beersLawLab.register( 'PrecipitateParticle', PrecipitateParticle );

  return inherit( SoluteParticle, PrecipitateParticle );
} );
Example #3
0
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var ChemUtils = require( 'NITROGLYCERIN/ChemUtils' );

  // strings
  var drinkMixString = require( 'string!BEERS_LAW_LAB/drinkMix' );

  var BLLSymbols = {
    COBALT_II_NITRATE: ChemUtils.toSubscript( 'Co(NO3)2' ),
    COBALT_CHLORIDE: ChemUtils.toSubscript( 'CoCl2' ),
    COPPER_SULFATE: ChemUtils.toSubscript( 'CuSO4' ),
    DRINK_MIX: drinkMixString,
    NICKEL_II_CHLORIDE: ChemUtils.toSubscript( 'NiCl2' ),
    POTASSIUM_CHROMATE: ChemUtils.toSubscript( 'K2CrO4' ),
    POTASSIUM_DICHROMATE: ChemUtils.toSubscript( 'K2Cr2O7' ),
    POTASSIUM_PERMANGANATE: ChemUtils.toSubscript( 'KMnO4' ),
    SODIUM_CHLORIDE: ChemUtils.toSubscript( 'NaCl' ),
    WATER: ChemUtils.toSubscript( 'H2O' )
  };

  beersLawLab.register( 'BLLSymbols', BLLSymbols );

  return BLLSymbols;
} );
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var ConcentrationSolution = require( 'BEERS_LAW_LAB/concentration/model/ConcentrationSolution' );
  var inherit = require( 'PHET_CORE/inherit' );
  var Rectangle = require( 'SCENERY/nodes/Rectangle' );

  /**
   * @param {Solvent} solvent
   * @param {Property.<Solute>} soluteProperty
   * @param {Dropper} dropper
   * @param {Beaker} beaker
   * @param {number} tipWidth
   * @param {ModelViewTransform2} modelViewTransform
   * @constructor
   */
  function StockSolutionNode( solvent, soluteProperty, dropper, beaker, tipWidth, modelViewTransform ) {

    var self = this;

    Rectangle.call( this, 0, 0, 0, 0, { lineWidth: 1 } );

    // shape and location
    var updateShapeAndLocation = function() {
      // path
      if ( dropper.dispensingProperty.get() && !dropper.emptyProperty.get() ) {
        self.setRect( -tipWidth / 2, 0, tipWidth, beaker.location.y - dropper.locationProperty.get().y );
      }
      else {
        self.setRect( 0, 0, 0, 0 );
      }
      // move this node to the dropper's location
      self.translation = modelViewTransform.modelToViewPosition( dropper.locationProperty.get() );
    };
    dropper.locationProperty.link( updateShapeAndLocation );
    dropper.dispensingProperty.link( updateShapeAndLocation );
    dropper.emptyProperty.link( updateShapeAndLocation );

    // set color to match solute
    soluteProperty.link( function( solute ) {
      var color = ConcentrationSolution.createColor( solvent, solute, solute.stockSolutionConcentration );
      self.fill = color;
      self.stroke = color.darkerColor();
    } );

    // hide this node when the dropper is invisible
    dropper.visibleProperty.link( function( visible ) {
      self.setVisible( visible );
    } );
  }

  beersLawLab.register( 'StockSolutionNode', StockSolutionNode );

  return inherit( Rectangle, StockSolutionNode );
} );
Example #5
0
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var BooleanProperty = require( 'AXON/BooleanProperty' );
  var inherit = require( 'PHET_CORE/inherit' );
  var NumberProperty = require( 'AXON/NumberProperty' );

  /**
   * @param {number} maxEvaporationRate L/sec
   * @param {ConcentrationSolution} solution
   * @param {Tandem} tandem
   * @constructor
   */
  function Evaporator( maxEvaporationRate, solution, tandem ) {

    var self = this;

    this.maxEvaporationRate = maxEvaporationRate; // @public (read-only) L/sec

    // @public
    this.evaporationRateProperty = new NumberProperty( 0, {
      tandem: tandem.createTandem( 'evaporationRateProperty' ),
      units: 'liters/second'
    } ); // L/sec
    this.enabledProperty = new BooleanProperty( true, {
      tandem: tandem.createTandem( 'enabledProperty' )
    } );

    // disable when the volume gets to zero
    solution.volumeProperty.link( function( volume ) {
      self.enabledProperty.set( volume > 0 );
    } );

    // when disabled, set the rate to zero
    this.enabledProperty.link( function( enabled ) {
      if ( !enabled ) {
        self.evaporationRateProperty.set( 0 );
      }
    } );
  }

  beersLawLab.register( 'Evaporator', Evaporator );

  return inherit( Object, Evaporator, {

    // @public
    reset: function() {
      this.evaporationRateProperty.reset();
      this.enabledProperty.reset();
    }
  } );
} );
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var ObjectIO = require( 'TANDEM/types/ObjectIO' );
  var phetioInherit = require( 'TANDEM/phetioInherit' );
  var validate = require( 'AXON/validate' );

  // ifphetio
  var phetioEngine = require( 'ifphetio!PHET_IO/phetioEngine' );

  /**
   * @param {BeersLawSolution} beersLawSolution
   * @param {string} phetioID
   * @constructor
   */
  function BeersLawSolutionIO( beersLawSolution, phetioID ) {
    ObjectIO.call( this, beersLawSolution, phetioID );
  }

  phetioInherit( ObjectIO, 'BeersLawSolutionIO', BeersLawSolutionIO, {}, {
    documentation: 'The solution for the sim',
    validator: { isValidValue: v => v instanceof phet.beersLawLab.BeersLawSolution },

    /**
     * Serializes an instance.
     * @param {BeersLawSolution} beersLawSolution
     * @returns {Object}
     * @override
     */
    toStateObject: function( beersLawSolution ) {
      validate( beersLawSolution, this.validator );
      return beersLawSolution.tandem.phetioID;
    },

    /**
     * Deserializes an instance.
     * Because the simulation has a Property that contains BeersLawSolution,
     * the Property relies on these methods for saving and loading the values.
     * @param {Object} stateObject
     * @returns {BeersLawSolution}
     * @override
     */
    fromStateObject: function( stateObject ) {
      return phetioEngine.getPhetioObject( stateObject );
    }
  } );

  beersLawLab.register( 'BeersLawSolutionIO', BeersLawSolutionIO );

  return BeersLawSolutionIO;
} );
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var Color = require( 'SCENERY/util/Color' );
  var inherit = require( 'PHET_CORE/inherit' );

  /**
   * @param {number} minConcentration - mol/L
   * @param {Color} minColor
   * @param {number} midConcentration - mol/L
   * @param {Color} midColor
   * @param {number} maxConcentration - mol/L (saturation point)
   * @param {Color} maxColor
   * @constructor
   */
  function SoluteColorScheme( minConcentration, minColor, midConcentration, midColor, maxConcentration, maxColor ) {
    this.minColor = minColor;
    this.midColor = midColor;
    this.maxColor = maxColor;
    this.minConcentration = minConcentration;
    this.midConcentration = midConcentration;
    this.maxConcentration = maxConcentration;
  }

  beersLawLab.register( 'SoluteColorScheme', SoluteColorScheme );

  return inherit( Object, SoluteColorScheme, {

    /**
     * Converts a concentration value to a Color, using a linear interpolation of RGB colors.
     * @param {number} concentration - mol/L
     * @returns {Color} color
     */
    concentrationToColor: function( concentration ) {
      if ( concentration >= this.maxConcentration ) {
        return this.maxColor;
      }
      else if ( concentration <= this.minConcentration ) {
        return this.minColor;
      }
      else if ( concentration <= this.midConcentration ) {
        return Color.interpolateRGBA( this.minColor, this.midColor, ( concentration - this.minConcentration ) / ( this.midConcentration - this.minConcentration ) );
      }
      else {
        return Color.interpolateRGBA( this.midColor, this.maxColor, ( concentration - this.midConcentration ) / ( this.maxConcentration - this.midConcentration ) );
      }
    }
  } );
} );
Example #8
0
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var BeersLawModel = require( 'BEERS_LAW_LAB/beerslaw/model/BeersLawModel' );
  var BeersLawScreenView = require( 'BEERS_LAW_LAB/beerslaw/view/BeersLawScreenView' );
  var Image = require( 'SCENERY/nodes/Image' );
  var inherit = require( 'PHET_CORE/inherit' );
  var ModelViewTransform2 = require( 'PHETCOMMON/view/ModelViewTransform2' );
  var Screen = require( 'JOIST/Screen' );
  var Vector2 = require( 'DOT/Vector2' );

  // strings
  var screenBeersLawString = require( 'string!BEERS_LAW_LAB/screen.beersLaw' );

  // image
  var screenIcon = require( 'image!BEERS_LAW_LAB/BeersLaw-screen-icon.jpg' );

  /**
   * @param {Tandem} tandem
   * @constructor
   */
  function BeersLawScreen( tandem ) {

    // No offset, scale 125x when going from model to view (1cm == 125 pixels)
    var modelViewTransform = ModelViewTransform2.createOffsetScaleMapping( new Vector2( 0, 0 ), 125 );

    var options = {
      name: screenBeersLawString,
      homeScreenIcon: new Image( screenIcon ),
      tandem: tandem
    };

    Screen.call( this,
      function() { return new BeersLawModel( modelViewTransform, tandem.createTandem( 'model' ) ); },
      function( model ) { return new BeersLawScreenView( model, modelViewTransform, tandem.createTandem( 'view' ) ); },
      options );
  }

  beersLawLab.register( 'BeersLawScreen', BeersLawScreen );

  return inherit( Screen, BeersLawScreen );
} );
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var ConcentrationModel = require( 'BEERS_LAW_LAB/concentration/model/ConcentrationModel' );
  var ConcentrationScreenView = require( 'BEERS_LAW_LAB/concentration/view/ConcentrationScreenView' );
  var Image = require( 'SCENERY/nodes/Image' );
  var inherit = require( 'PHET_CORE/inherit' );
  var ModelViewTransform2 = require( 'PHETCOMMON/view/ModelViewTransform2' );
  var Screen = require( 'JOIST/Screen' );

  // strings
  var screenConcentrationString = require( 'string!BEERS_LAW_LAB/screen.concentration' );

  // images
  var screenIcon = require( 'image!BEERS_LAW_LAB/Concentration-screen-icon.jpg' );

  /**
   * @param {Tandem} tandem
   * @constructor
   */
  function ConcentrationScreen( tandem ) {

    var modelViewTransform = ModelViewTransform2.createIdentity();

    var options = {
      name: screenConcentrationString,
      homeScreenIcon: new Image( screenIcon ),
      tandem: tandem
    };

    Screen.call( this,
      function() { return new ConcentrationModel( { tandem: tandem.createTandem( 'model' ) } ); },
      function( model ) { return new ConcentrationScreenView( model, modelViewTransform, tandem.createTandem( 'view' ) ); },
      options );
  }

  beersLawLab.register( 'ConcentrationScreen', ConcentrationScreen );

  return inherit( Screen, ConcentrationScreen );
} );
Example #10
0
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var inherit = require( 'PHET_CORE/inherit' );
  var PhetFont = require( 'SCENERY_PHET/PhetFont' );
  var StringUtils = require( 'PHETCOMMON/util/StringUtils' );
  var Text = require( 'SCENERY/nodes/Text' );
  var Util = require( 'DOT/Util' );

  // strings
  var pattern0SoluteAmountString = require( 'string!BEERS_LAW_LAB/pattern.0soluteAmount' );

  // constants
  var DECIMAL_PLACES = 0;

  /**
   * @param {Property.<number>} soluteGramsProperty - grams of solute
   * @param {Object} [options]
   * @constructor
   */
  function SoluteGramsNode( soluteGramsProperty, options ) {

    options = _.extend( {
      font: new PhetFont( 22 )
    }, options );

    var self = this;

    Text.call( this, '', options );

    soluteGramsProperty.link( function( soluteGrams ) {
      self.text = StringUtils.format( pattern0SoluteAmountString, Util.toFixed( soluteGrams, DECIMAL_PLACES ) );
    } );
  }

  beersLawLab.register( 'SoluteGramsNode', SoluteGramsNode );

  return inherit( Text, SoluteGramsNode );
} );
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var ConcentrationControl = require( 'BEERS_LAW_LAB/beerslaw/view/ConcentrationControl' );
  var inherit = require( 'PHET_CORE/inherit' );
  var Panel = require( 'SUN/Panel' );
  var SolutionComboBox = require( 'BEERS_LAW_LAB/beerslaw/view/SolutionComboBox' );
  var ToggleNode = require( 'SUN/ToggleNode' );
  var VBox = require( 'SCENERY/nodes/VBox' );

  /**
   * @param {BeersLawSolution[]} solutions
   * @param {Property.<BeersLawSolution>} currentSolutionProperty
   * @param {Node} solutionListParent
   * @param {Tandem} tandem
   * @param {Object} [options]
   * @constructor
   */
  function SolutionControls( solutions, currentSolutionProperty, solutionListParent, tandem, options ) {

    options = _.extend( {
      xMargin: 15,
      yMargin: 15,
      fill: '#F0F0F0',
      stroke: 'gray',
      lineWidth: 1,
      tandem: tandem
    }, options );

    // combo box, to select a solution
    var comboBox = new SolutionComboBox( solutions, currentSolutionProperty, solutionListParent, tandem.createTandem( 'comboBox' ) );

    // {{value:{BeersLawSolution}, node:{ConcentrationControl}} - concentration controls, one for each solution
    var toggleNodeElements = solutions.map( function( solution ) {
      return {
        value: solution,
        node: new ConcentrationControl( solution, {
          visible: false,
          tandem: tandem.createTandem( solution.internalName + 'ConcentrationControl' ),
          phetioDocumentation: 'the concentration control for ' + solution.name
        } )
      };
    } );

    // Makes the control visible for the selected solution
    var toggleNode = new ToggleNode( currentSolutionProperty, toggleNodeElements );

    var contentNode = new VBox( {
      spacing: 15,
      align: 'left',
      children: [ comboBox, toggleNode ]
    } );

    Panel.call( this, contentNode, options );
  }

  beersLawLab.register( 'SolutionControls', SolutionControls );

  return inherit( Panel, SolutionControls );
} );
define( function( require ) {
  'use strict';

  // modules
  var Beaker = require( 'BEERS_LAW_LAB/concentration/model/Beaker' );
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var BLLConstants = require( 'BEERS_LAW_LAB/common/BLLConstants' );
  var Bounds2 = require( 'DOT/Bounds2' );
  var ConcentrationMeter = require( 'BEERS_LAW_LAB/concentration/model/ConcentrationMeter' );
  var ConcentrationModelIO = require( 'BEERS_LAW_LAB/concentration/model/ConcentrationModelIO' );
  var ConcentrationSolution = require( 'BEERS_LAW_LAB/concentration/model/ConcentrationSolution' );
  var Dimension2 = require( 'DOT/Dimension2' );
  var Dropper = require( 'BEERS_LAW_LAB/concentration/model/Dropper' );
  var Evaporator = require( 'BEERS_LAW_LAB/concentration/model/Evaporator' );
  var Faucet = require( 'BEERS_LAW_LAB/concentration/model/Faucet' );
  var inherit = require( 'PHET_CORE/inherit' );
  var PhetioObject = require( 'TANDEM/PhetioObject' );
  var Precipitate = require( 'BEERS_LAW_LAB/concentration/model/Precipitate' );
  var Property = require( 'AXON/Property' );
  var PropertyIO = require( 'AXON/PropertyIO' );
  var Shaker = require( 'BEERS_LAW_LAB/concentration/model/Shaker' );
  var ShakerParticles = require( 'BEERS_LAW_LAB/concentration/model/ShakerParticles' );
  var Solute = require( 'BEERS_LAW_LAB/concentration/model/Solute' );
  var SoluteIO = require( 'BEERS_LAW_LAB/concentration/model/SoluteIO' );
  var StringProperty = require( 'AXON/StringProperty' );
  var Tandem = require( 'TANDEM/Tandem' );
  var Vector2 = require( 'DOT/Vector2' );

  // constants
  var SOLUTION_VOLUME_RANGE = BLLConstants.SOLUTION_VOLUME_RANGE; // L
  var SOLUTE_AMOUNT_RANGE = BLLConstants.SOLUTE_AMOUNT_RANGE; // moles
  var MAX_EVAPORATION_RATE = 0.25; // L/sec
  var MAX_FAUCET_FLOW_RATE = 0.25; // L/sec
  var DROPPER_FLOW_RATE = 0.05; // L/sec
  var SHAKER_MAX_DISPENSING_RATE = 0.2; // mol/sec

  /**
   * @param {Object} [options]
   * @constructor
   */
  function ConcentrationModel( options ) {

    var self = this;

    options = _.extend( {
      tandem: Tandem.required,
      phetioType: ConcentrationModelIO,
      phetioState: false // does not contribute self-state, all of the state is from child instances (via composition)
    }, options );

    var tandem = options.tandem;

    // @public Solutes, in rainbow (ROYGBIV) order.
    this.solutes = [
      Solute.DRINK_MIX,
      Solute.COBALT_II_NITRATE,
      Solute.COBALT_CHLORIDE,
      Solute.POTASSIUM_DICHROMATE,
      Solute.POTASSIUM_CHROMATE,
      Solute.NICKEL_II_CHLORIDE,
      Solute.COPPER_SULFATE,
      Solute.POTASSIUM_PERMANGANATE,
      Solute.SODIUM_CHLORIDE
    ];

    // @public
    this.soluteProperty = new Property( this.solutes[ 0 ], {
      tandem: tandem.createTandem( 'soluteProperty' ),
      phetioType: PropertyIO( SoluteIO )
    } );
    this.soluteFormProperty = new StringProperty( 'solid', {
      validValues: [ 'solid', 'solution' ],
      tandem: tandem.createTandem( 'soluteFormProperty' )
    } );

    // @public
    this.solution = new ConcentrationSolution( this.soluteProperty, SOLUTE_AMOUNT_RANGE.defaultValue, SOLUTION_VOLUME_RANGE.defaultValue, tandem.createTandem( 'solution' ) );
    this.beaker = new Beaker( new Vector2( 350, 550 ), new Dimension2( 600, 300 ), 1 );
    this.precipitate = new Precipitate( this.solution, this.beaker, { tandem: tandem.createTandem( 'precipitate' ) } );
    this.shaker = new Shaker(
      new Vector2( this.beaker.location.x, 170 ),
      new Bounds2( 250, 50, 575, 210 ),
      0.75 * Math.PI,
      this.soluteProperty,
      SHAKER_MAX_DISPENSING_RATE,
      this.soluteFormProperty.get() === 'solid', {
        tandem: tandem.createTandem( 'shaker' )
      } );
    this.shakerParticles = new ShakerParticles( this.shaker, this.solution, this.beaker, { tandem: tandem.createTandem( 'shakerParticles' ) } );
    this.dropper = new Dropper(
      new Vector2( this.beaker.location.x, 225 ),
      new Bounds2( 260, 225, 580, 225 ),
      this.soluteProperty,
      DROPPER_FLOW_RATE,
      this.soluteFormProperty.get() === 'solution', {
        tandem: tandem.createTandem( 'dropper' )
      }
    );
    this.evaporator = new Evaporator( MAX_EVAPORATION_RATE, this.solution, tandem.createTandem( 'evaporator' ) );
    this.solventFaucet = new Faucet( new Vector2( 155, 220 ), -400, 45, MAX_FAUCET_FLOW_RATE, tandem.createTandem( 'solventFaucet' ) );
    this.drainFaucet = new Faucet( new Vector2( 750, 630 ), this.beaker.getRight(), 45, MAX_FAUCET_FLOW_RATE, tandem.createTandem( 'drainFaucet' ) );
    this.concentrationMeter = new ConcentrationMeter(
      new Vector2( 785, 210 ), new Bounds2( 10, 150, 835, 680 ),
      new Vector2( 750, 370 ), new Bounds2( 30, 150, 966, 680 ), {
        tandem: tandem.createTandem( 'concentrationMeter' )
      } );

    // When the solute is changed, the amount of solute resets to 0.  This is a lazyLink instead of link so that
    // the simulation can be launched with a nonzero solute amount (using PhET-iO)
    this.soluteProperty.lazyLink( function() {
      self.solution.soluteAmountProperty.set( 0 );
    } );

    // Enable faucets and dropper based on amount of solution in the beaker.
    this.solution.volumeProperty.link( function( volume ) {
      self.solventFaucet.enabledProperty.set( volume < SOLUTION_VOLUME_RANGE.max );
      self.drainFaucet.enabledProperty.set( volume > SOLUTION_VOLUME_RANGE.min );
      self.dropper.enabledProperty.set( !self.dropper.emptyProperty.get() && ( volume < SOLUTION_VOLUME_RANGE.max ) );
    } );

    // Empty shaker and dropper when max solute is reached.
    this.solution.soluteAmountProperty.link( function( soluteAmount ) {
      var containsMaxSolute = ( soluteAmount >= SOLUTE_AMOUNT_RANGE.max );
      self.shaker.emptyProperty.set( containsMaxSolute );
      self.dropper.emptyProperty.set( containsMaxSolute );
      self.dropper.enabledProperty.set( !self.dropper.emptyProperty.get() && !containsMaxSolute && self.solution.volumeProperty.get() < SOLUTION_VOLUME_RANGE.max );
    } );

    PhetioObject.call( this, options );
  }

  beersLawLab.register( 'ConcentrationModel', ConcentrationModel );

  return inherit( PhetioObject, ConcentrationModel, {

    /*
     * May be called from PhET-iO before the UI is constructed to choose a different set of solutes.  The first solute
     * becomes the selected solute
     * @param {Array.<Solute>} solutes
     * @public
     */
    setSolutes: function( solutes ) {
      assert && assert( solutes.length > 0, 'Must specify at least one solute' );
      this.solutes = solutes;
      this.soluteProperty.value = solutes[ 0 ];
    },

    // @public Resets all model elements
    reset: function() {
      this.soluteProperty.reset();
      this.soluteFormProperty.reset();
      this.solution.reset();
      this.shaker.reset();
      this.shakerParticles.reset();
      this.dropper.reset();
      this.evaporator.reset();
      this.solventFaucet.reset();
      this.drainFaucet.reset();
      this.concentrationMeter.reset();
    },

    /*
     * Moves time forward by the specified amount.
     * @param deltaSeconds clock time change, in seconds.
     * @public
     */
    step: function( deltaSeconds ) {
      this.addSolventFromInputFaucet( deltaSeconds );
      this.drainSolutionFromOutputFaucet( deltaSeconds );
      this.addStockSolutionFromDropper( deltaSeconds );
      this.evaporateSolvent( deltaSeconds );
      this.propagateShakerParticles( deltaSeconds );
      this.createShakerParticles();
    },

    // @private Add solvent from the input faucet
    addSolventFromInputFaucet: function( deltaSeconds ) {
      this.addSolvent( this.solventFaucet.flowRateProperty.get() * deltaSeconds );
    },

    // @private Drain solution from the output faucet
    drainSolutionFromOutputFaucet: function( deltaSeconds ) {
      var drainVolume = this.drainFaucet.flowRateProperty.get() * deltaSeconds;
      if ( drainVolume > 0 ) {
        var concentration = this.solution.concentrationProperty.get(); // get concentration before changing volume
        var volumeRemoved = this.removeSolvent( drainVolume );
        this.removeSolute( concentration * volumeRemoved );
      }
    },

    // @private Add stock solution from dropper
    addStockSolutionFromDropper: function( deltaSeconds ) {
      var dropperVolume = this.dropper.flowRateProperty.get() * deltaSeconds;
      if ( dropperVolume > 0 ) {

        // defer update of precipitateAmount until we've changed both volume and solute amount, see concentration#1
        this.solution.updatePrecipitateAmount = false;
        var volumeAdded = this.addSolvent( dropperVolume );
        this.solution.updatePrecipitateAmount = true;
        this.addSolute( this.solution.soluteProperty.get().stockSolutionConcentration * volumeAdded );
      }
    },

    // @private Evaporate solvent
    evaporateSolvent: function( deltaSeconds ) {
      this.removeSolvent( this.evaporator.evaporationRateProperty.get() * deltaSeconds );
    },

    // @private Propagates solid solute that came out of the shaker
    propagateShakerParticles: function( deltaSeconds ) {
      this.shakerParticles.step( deltaSeconds );
    },

    // @private Creates new solute particles when the shaker is shaken.
    createShakerParticles: function() {
      this.shaker.step();
    },

    // @private Adds solvent to the solution. Returns the amount actually added.
    addSolvent: function( deltaVolume ) {
      if ( deltaVolume > 0 ) {
        var volumeProperty = this.solution.volumeProperty;
        var volumeBefore = volumeProperty.get();
        volumeProperty.set( Math.min( SOLUTION_VOLUME_RANGE.max, volumeProperty.get() + deltaVolume ) );
        return volumeProperty.get() - volumeBefore;
      }
      else {
        return 0;
      }
    },

    // @private Removes solvent from the solution. Returns the amount actually removed.
    removeSolvent: function( deltaVolume ) {
      if ( deltaVolume > 0 ) {
        var volumeProperty = this.solution.volumeProperty;
        var volumeBefore = volumeProperty.get();
        volumeProperty.set( Math.max( SOLUTION_VOLUME_RANGE.min, volumeProperty.get() - deltaVolume ) );
        return volumeBefore - volumeProperty.get();
      }
      else {
        return 0;
      }
    },

    // @private Adds solute to the solution. Returns the amount actually added.
    addSolute: function( deltaAmount ) {
      if ( deltaAmount > 0 ) {
        var amountBefore = this.solution.soluteAmountProperty.get();
        this.solution.soluteAmountProperty.set( Math.min( SOLUTE_AMOUNT_RANGE.max, this.solution.soluteAmountProperty.get() + deltaAmount ) );
        return this.solution.soluteAmountProperty.get() - amountBefore;
      }
      else {
        return 0;
      }
    },

    // @private Removes solute from the solution. Returns the amount actually removed.
    removeSolute: function( deltaAmount ) {
      if ( deltaAmount > 0 ) {
        var amountBefore = this.solution.soluteAmountProperty.get();
        this.solution.soluteAmountProperty.set( Math.max( SOLUTE_AMOUNT_RANGE.min, this.solution.soluteAmountProperty.get() - deltaAmount ) );
        return amountBefore - this.solution.soluteAmountProperty.get();
      }
      else {
        return 0;
      }
    }
  }, {
    SOLUTION_VOLUME_RANGE: SOLUTION_VOLUME_RANGE // Exported for access to phet-io API
  } );
} );
define( function( require ) {
  'use strict';

  // modules
  var ATDetectorNode = require( 'BEERS_LAW_LAB/beerslaw/view/ATDetectorNode' );
  var BeamNode = require( 'BEERS_LAW_LAB/beerslaw/view/BeamNode' );
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var BLLConstants = require( 'BEERS_LAW_LAB/common/BLLConstants' );
  var BLLQueryParameters = require( 'BEERS_LAW_LAB/common/BLLQueryParameters' );
  var BLLRulerNode = require( 'BEERS_LAW_LAB/beerslaw/view/BLLRulerNode' );
  var CuvetteNode = require( 'BEERS_LAW_LAB/beerslaw/view/CuvetteNode' );
  var inherit = require( 'PHET_CORE/inherit' );
  var LightNode = require( 'BEERS_LAW_LAB/beerslaw/view/LightNode' );
  var Node = require( 'SCENERY/nodes/Node' );
  var ResetAllButton = require( 'SCENERY_PHET/buttons/ResetAllButton' );
  var ScreenView = require( 'JOIST/ScreenView' );
  var SolutionControls = require( 'BEERS_LAW_LAB/beerslaw/view/SolutionControls' );
  var WavelengthControls = require( 'BEERS_LAW_LAB/beerslaw/view/WavelengthControls' );

  /**
   * @param {BeersLawModel} model
   * @param {ModelViewTransform2} modelViewTransform
   * @param {Tandem} tandem
   * @constructor
   */
  function BeersLawScreenView( model, modelViewTransform, tandem ) {

    ScreenView.call( this, _.extend( {
        tandem: tandem
      }, BLLConstants.SCREEN_VIEW_OPTIONS ) );

    var lightNode = new LightNode( model.light, modelViewTransform, tandem.createTandem( 'lightNode' ) );
    var cuvetteNode = new CuvetteNode( model.cuvette, model.solutionProperty, modelViewTransform, BLLQueryParameters.cuvetteSnapInterval, tandem.createTandem( 'cuvetteNode' ) );
    var beamNode = new BeamNode( model.beam );
    var detectorNode = new ATDetectorNode( model.detector, model.light, modelViewTransform, tandem.createTandem( 'detectorNode' ) );
    var wavelengthControls = new WavelengthControls( model.solutionProperty, model.light, tandem.createTandem( 'wavelengthControls' ) );
    var rulerNode = new BLLRulerNode( model.ruler, modelViewTransform, tandem.createTandem( 'rulerNode' ) );
    var comboBoxListParent = new Node( { maxWidth: 500 } );
    var solutionControls = new SolutionControls( model.solutions, model.solutionProperty, comboBoxListParent, tandem.createTandem( 'solutionControls' ), { maxWidth: 575 } );

    // Reset All button
    var resetAllButton = new ResetAllButton( {
      scale: 1.32,
      listener: function() {
        model.reset();
        wavelengthControls.reset();
      },
      tandem: tandem.createTandem( 'resetAllButton' )
    } );

    // Rendering order
    this.addChild( wavelengthControls );
    this.addChild( resetAllButton );
    this.addChild( solutionControls );
    this.addChild( detectorNode );
    this.addChild( cuvetteNode );
    this.addChild( beamNode );
    this.addChild( lightNode );
    this.addChild( rulerNode );
    this.addChild( comboBoxListParent ); // last, so that combo box list is on top

    // Layout for things that don't have a location in the model.
    {
      // below the light
      wavelengthControls.left = lightNode.left;
      wavelengthControls.top = lightNode.bottom + 20;
      // below cuvette
      solutionControls.left = cuvetteNode.left;
      solutionControls.top = cuvetteNode.bottom + 60;
      // bottom right
      resetAllButton.right = this.layoutBounds.right - 30;
      resetAllButton.bottom = this.layoutBounds.bottom - 30;
    }
  }

  beersLawLab.register( 'BeersLawScreenView', BeersLawScreenView );

  return inherit( ScreenView, BeersLawScreenView );
} );
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var ObjectIO = require( 'TANDEM/types/ObjectIO' );
  var phetioInherit = require( 'TANDEM/phetioInherit' );
  var ShakerParticle = require( 'BEERS_LAW_LAB/concentration/model/ShakerParticle' );
  var ShakerParticleIO = require( 'BEERS_LAW_LAB/concentration/model/ShakerParticleIO' );
  var Vector2 = require( 'DOT/Vector2' );
  var validate = require( 'AXON/validate' );

  /**
   * @param {ShakerParticles} shakerParticles
   * @param {string} phetioID
   * @constructor
   */
  function ShakerParticlesIO( shakerParticles, phetioID ) {
    ObjectIO.call( this, shakerParticles, phetioID );
  }

  phetioInherit( ObjectIO, 'ShakerParticlesIO', ShakerParticlesIO, {}, {

    documentation: 'Base type for a group of particles.',
    validator: { isValidValue: v => v instanceof phet.beersLawLab.ShakerParticles },

    /**
     * Overrides the super type toStateObject (which was grabbing JSON for the entire instance) to return an empty instance.
     * The state is set through child instances and composition.
     * @returns {Object}
     */
    toStateObject: function() {return {};},

    /**
     * Clears the children from the model so it can be deserialized.
     * @param {ShakerParticles} shakerParticles
     */
    clearChildInstances: function( shakerParticles ) {
      validate( shakerParticles, this.validator );

      shakerParticles.removeAllParticles();

      // Particles.step is not called in playback mode, so this needs to be called explicitly to update the view.
      shakerParticles.fireChanged();
    },

    /**
     * Create a dynamic particle as specified by the phetioID and state.
     * @param {ShakerParticles} shakerParticles
     * @param {Tandem} tandem
     * @param {Object} stateObject
     * @returns {ChargedParticle}
     */
    addChildInstance: function( shakerParticles, tandem, stateObject ) {
      validate( shakerParticles, this.validator );

      var value = ShakerParticleIO.fromStateObject( stateObject );
      assert && assert( value.acceleration instanceof Vector2, 'acceleration should be a Vector2' );

      shakerParticles.addParticle( new ShakerParticle(
        value.solute,
        value.location,
        value.orientation,
        value.velocity,
        value.acceleration, {
          tandem: tandem
        }
      ) );

      // Particles.step is not called in playback mode, so this needs to be called explicitly to update the view.
      shakerParticles.fireChanged();
    }
  } );

  beersLawLab.register( 'ShakerParticlesIO', ShakerParticlesIO );

  return ShakerParticlesIO;
} );
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var BeersLawSolutionIO = require( 'BEERS_LAW_LAB/beerslaw/model/BeersLawSolutionIO' );
  var BLLConstants = require( 'BEERS_LAW_LAB/common/BLLConstants' );
  var BLLSymbols = require( 'BEERS_LAW_LAB/common/BLLSymbols' );
  var Color = require( 'SCENERY/util/Color' );
  var ColorRange = require( 'BEERS_LAW_LAB/common/model/ColorRange' );
  var ConcentrationTransform = require( 'BEERS_LAW_LAB/beerslaw/model/ConcentrationTransform' );
  var DerivedProperty = require( 'AXON/DerivedProperty' );
  var inherit = require( 'PHET_CORE/inherit' );
  var MolarAbsorptivityData = require( 'BEERS_LAW_LAB/beerslaw/model/MolarAbsorptivityData' );
  var NumberProperty = require( 'AXON/NumberProperty' );
  var PhetioObject = require( 'TANDEM/PhetioObject' );
  var RangeWithValue = require( 'DOT/RangeWithValue' );
  var Solvent = require( 'BEERS_LAW_LAB/common/model/Solvent' );
  var StringUtils = require( 'PHETCOMMON/util/StringUtils' );
  var Tandem = require( 'TANDEM/Tandem' );
  var Util = require( 'DOT/Util' );

  // strings
  var cobaltChlorideString = require( 'string!BEERS_LAW_LAB/cobaltChloride' );
  var cobaltIINitrateString = require( 'string!BEERS_LAW_LAB/cobaltIINitrate' );
  var copperSulfateString = require( 'string!BEERS_LAW_LAB/copperSulfate' );
  var drinkMixString = require( 'string!BEERS_LAW_LAB/drinkMix' );
  var nickelIIChlorideString = require( 'string!BEERS_LAW_LAB/nickelIIChloride' );
  var pattern0Formula1NameString = require( 'string!BEERS_LAW_LAB/pattern.0formula.1name' );
  var potassiumChromateString = require( 'string!BEERS_LAW_LAB/potassiumChromate' );
  var potassiumDichromateString = require( 'string!BEERS_LAW_LAB/potassiumDichromate' );
  var potassiumPermanganateString = require( 'string!BEERS_LAW_LAB/potassiumPermanganate' );

  /**
   * @param {string} internalName - used internally, not displayed to the user
   * @param {string} name - name that is visible to the user
   * @param {string} formula - formula that is visible to the user
   * @param {MolarAbsorptivityData} molarAbsorptivityData
   * @param {RangeWithValue} concentrationRange
   * @param {ConcentrationTransform} concentrationTransform
   * @param {ColorRange} colorRange
   * @param {Object} [options]
   * @constructor
   */
  function BeersLawSolution( internalName, name, formula, molarAbsorptivityData, concentrationRange, concentrationTransform,
                             colorRange, options ) {
    
    assert && assert( internalName.indexOf( ' ' ) === -1, 'internalName cannot contain spaces: ' + internalName );

    options = _.extend( {
      saturatedColor: colorRange.max, // {Color} color to use when the solution is saturated
      phetioType: BeersLawSolutionIO,
      tandem: Tandem.required
    }, options );

    PhetioObject.call( this, options );

    var self = this;

    // @public (read-only)
    this.solvent = Solvent.WATER;
    this.internalName = internalName;
    this.name = name;
    this.formula = formula;
    this.molarAbsorptivityData = molarAbsorptivityData;
    this.concentrationProperty = new NumberProperty( concentrationRange.defaultValue, {
      units: 'moles/liter',
      range: concentrationRange,
      tandem: options.tandem.createTandem( 'concentrationProperty' )
    } );
    this.concentrationRange = concentrationRange;
    this.concentrationTransform = concentrationTransform;
    this.colorRange = colorRange;
    this.saturatedColor = options.saturatedColor;

    // @public Solution color is derived from concentration
    this.fluidColorProperty = new DerivedProperty( [ this.concentrationProperty ],
      function( concentration ) {
        var color = self.solvent.colorProperty.get();
        if ( concentration > 0 ) {
          var distance = Util.linear( self.concentrationRange.min, self.concentrationRange.max, 0, 1, concentration );
          color = self.colorRange.interpolateLinear( distance );
        }
        return color;
      } );

    // @public - the name of the solution in tandem id format. Used to other make tandems that pertain to this solution.
    this.tandemName = options.tandem.tail;
  }

  beersLawLab.register( 'BeersLawSolution', BeersLawSolution );

  inherit( PhetioObject, BeersLawSolution, {

    // @public
    reset: function() {
      this.concentrationProperty.reset();
    },

    // @public
    getDisplayName: function() {
      if ( this.formula === this.name ) {
        return this.name;
      }
      return StringUtils.format( pattern0Formula1NameString, this.formula, this.name );
    }
  } );

  // A new tandem instance is required here since the solutes are created statically.  Signify that these solutions
  // are only used in the beers law screen by attaching them to that screen's tandem.
  var tandem = BLLConstants.BEERS_LAW_SCREEN_TANDEM.createTandem( 'solutions' );

  //-------------------------------------------------------------------------------------------
  // Specific solutions below ...
  //-------------------------------------------------------------------------------------------

  BeersLawSolution.DRINK_MIX = new BeersLawSolution(
    'drinkMix',
    drinkMixString,
    BLLSymbols.DRINK_MIX,
    MolarAbsorptivityData.DRINK_MIX,
    new RangeWithValue( 0, 0.400, 0.100 ),
    ConcentrationTransform.mM,
    new ColorRange( new Color( 255, 225, 225 ), Color.RED ), {
      tandem: tandem.createTandem( 'drinkMix' )
    }
  );

  BeersLawSolution.COBALT_II_NITRATE = new BeersLawSolution(
    'cobaltIINitrate',
    cobaltIINitrateString,
    BLLSymbols.COBALT_II_NITRATE,
    MolarAbsorptivityData.COBALT_II_NITRATE,
    new RangeWithValue( 0, 0.400, 0.100 ),
    ConcentrationTransform.mM,
    new ColorRange( new Color( 255, 225, 225 ), Color.RED ), {
      tandem: tandem.createTandem( 'cobaltIINitrate' )
    }
  );

  BeersLawSolution.COBALT_CHLORIDE = new BeersLawSolution(
    'cobaltChloride',
    cobaltChlorideString,
    BLLSymbols.COBALT_CHLORIDE,
    MolarAbsorptivityData.COBALT_CHLORIDE,
    new RangeWithValue( 0, 0.250, 0.100 ),
    ConcentrationTransform.mM,
    new ColorRange( new Color( 255, 242, 242 ), new Color( 255, 106, 106 ) ), {
      tandem: tandem.createTandem( 'cobaltChloride' )
    }
  );

  BeersLawSolution.POTASSIUM_DICHROMATE = new BeersLawSolution(
    'potassiumDichromate',
    potassiumDichromateString,
    BLLSymbols.POTASSIUM_DICHROMATE,
    MolarAbsorptivityData.POTASSIUM_DICHROMATE,
    new RangeWithValue( 0, 0.000500, 0.000100 ),
    ConcentrationTransform.uM,
    new ColorRange( new Color( 255, 232, 210 ), new Color( 255, 127, 0 ) ), {
      tandem: tandem.createTandem( 'potassiumDichromate' )
    }
  );

  BeersLawSolution.POTASSIUM_CHROMATE = new BeersLawSolution(
    'potassiumChromate',
    potassiumChromateString,
    BLLSymbols.POTASSIUM_CHROMATE,
    MolarAbsorptivityData.POTASSIUM_CHROMATE,
    new RangeWithValue( 0, 0.000400, 0.000100 ),
    ConcentrationTransform.uM,
    new ColorRange( new Color( 255, 255, 199 ), new Color( 255, 255, 0 ) ), {
      tandem: tandem.createTandem( 'potassiumChromate' )
    }
  );

  BeersLawSolution.NICKEL_II_CHLORIDE = new BeersLawSolution(
    'nickelIIChloride',
    nickelIIChlorideString,
    BLLSymbols.NICKEL_II_CHLORIDE,
    MolarAbsorptivityData.NICKEL_II_CHLORIDE,
    new RangeWithValue( 0, 0.350, 0.100 ),
    ConcentrationTransform.mM,
    new ColorRange( new Color( 234, 244, 234 ), new Color( 0, 128, 0 ) ), {
      tandem: tandem.createTandem( 'nickelIIChloride' )
    }
  );

  BeersLawSolution.COPPER_SULFATE = new BeersLawSolution(
    'copperSulfate',
    copperSulfateString,
    BLLSymbols.COPPER_SULFATE,
    MolarAbsorptivityData.COPPER_SULFATE,
    new RangeWithValue( 0, 0.200, 0.100 ),
    ConcentrationTransform.mM,
    new ColorRange( new Color( 222, 238, 255 ), new Color( 30, 144, 255 ) ), {
      tandem: tandem.createTandem( 'copperSulfate' )
    }
  );

  BeersLawSolution.POTASSIUM_PERMANGANATE = new BeersLawSolution(
    'potassiumPermanganate',
    potassiumPermanganateString,
    BLLSymbols.POTASSIUM_PERMANGANATE,
    MolarAbsorptivityData.POTASSIUM_PERMANGANATE,
    new RangeWithValue( 0, 0.000800, 0.000100 ),
    ConcentrationTransform.uM,
    new ColorRange( new Color( 255, 235, 255 ), new Color( 255, 0, 255 ) ), {
      tandem: tandem.createTandem( 'potassiumPermanganate' ),

      // has a special saturated color
      saturatedColor: new Color( 80, 0, 120 )
    }
  );

  return BeersLawSolution;
} );
Example #16
0
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var ObjectIO = require( 'TANDEM/types/ObjectIO' );
  var phetioInherit = require( 'TANDEM/phetioInherit' );
  var StringIO = require( 'TANDEM/types/StringIO' );
  var VoidIO = require( 'TANDEM/types/VoidIO' );
  var validate = require( 'AXON/validate' );

  // ifphetio
  var phetioEngine = require( 'ifphetio!PHET_IO/phetioEngine' );

  /**
   * @param {Solute} solute
   * @param {string} phetioID
   * @constructor
   */
  function SoluteIO( solute, phetioID ) {
    ObjectIO.call( this, solute, phetioID );
  }

  phetioInherit( ObjectIO, 'SoluteIO', SoluteIO, {

    setName: {
      returnType: VoidIO,
      parameterTypes: [ StringIO ],
      implementation: function( text ) {
        this.instance.name = text;
      },
      documentation: 'Set the name of the solute',
      invocableForReadOnlyElements: false
    },

    setFormula: {
      returnType: VoidIO,
      parameterTypes: [ StringIO ],
      implementation: function( text ) {
        this.instance.formula = text;
      },
      documentation: 'Set the formula of the solute',
      invocableForReadOnlyElements: false
    }
  }, {
    documentation: 'The Solute for the sim.',
    validator: { isValidValue: v => v instanceof phet.beersLawLab.Solute },

    /**
     * Serializes an instance.
     * @param {Solute} solute
     * @returns {Object}
     */
    toStateObject: function( solute ) {
      validate( solute, this.validator );
      return solute.tandem.phetioID;
    },

    /**
     * Deserializes an instance.
     * @param {Object} stateObject
     * @returns {Solute}
     */
    fromStateObject: function( stateObject ) {
      return phetioEngine.getPhetioObject( stateObject );
    }
  } );

  beersLawLab.register( 'SoluteIO', SoluteIO );

  return SoluteIO;
} );
define( function( require ) {
  'use strict';

  // modules
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var Dimension2 = require( 'DOT/Dimension2' );
  var DynamicProperty = require( 'AXON/DynamicProperty' );
  var HBox = require( 'SCENERY/nodes/HBox' );
  var HStrut = require( 'SCENERY/nodes/HStrut' );
  var inherit = require( 'PHET_CORE/inherit' );
  var LinearGradient = require( 'SCENERY/util/LinearGradient' );
  var NumberControl = require( 'SCENERY_PHET/NumberControl' );
  var PhetFont = require( 'SCENERY_PHET/PhetFont' );
  var Property = require( 'AXON/Property' );
  var Range = require( 'DOT/Range' );
  var StringUtils = require( 'PHETCOMMON/util/StringUtils' );
  var SunConstants = require( 'SUN/SunConstants' );
  var Text = require( 'SCENERY/nodes/Text' );
  var Util = require( 'DOT/Util' );

  // strings
  var concentrationString = require( 'string!BEERS_LAW_LAB/concentration' );
  var pattern0LabelString = require( 'string!BEERS_LAW_LAB/pattern.0label' );
  var pattern0Value1UnitsString = require( 'string!BEERS_LAW_LAB/pattern.0value.1units' );

  // constants
  var FONT = new PhetFont( 20 );
  var TICK_FONT = new PhetFont( 16 );
  var SLIDER_INTERVAL = 5; // in view units

  /**
   * @param {BeersLawSolution} solution
   * @param {Object} [options]
   * @constructor
   */
  function ConcentrationControl( solution, options ) {

    options = _.extend( {

      // NumberControl options
      titleNodeOptions: {
        font: FONT
      },
      numberDisplayOptions: {
        font: FONT,
        minBackgroundWidth: 95 // determined empirically
      },
      arrowButtonOptions: {
        scale: 1,
        touchAreaXDilation: 8,
        touchAreaYDilation: 15
      },

      // Slider options, passed through by NumberControl
      sliderOptions: {
        trackSize: new Dimension2( 200, 15 ),
        thumbSize: new Dimension2( 22, 45 ),
        constrainValue: function( value ) {
          return Util.roundToInterval( value, SLIDER_INTERVAL );
        }
      },

      // single-line horizontal layout
      layoutFunction: function( titleNode, numberDisplay, slider, leftArrowButton, rightArrowButton ) {
        return new HBox( {
          spacing: 5,
          children: [ titleNode, numberDisplay, new HStrut( 5 ), leftArrowButton, slider, rightArrowButton ]
        } );
      }
    }, options );

    // @public (read-only)
    this.solution = solution;

    var transform = solution.concentrationTransform;

    var title = StringUtils.format( pattern0LabelString, concentrationString );

    // e.g. display units that are specific to the solution, e.g. '{0} mM'
    assert && assert( !options.numberDisplayOptions.valuePattern, 'ConcentrationControl sets valuePattern' );
    options.numberDisplayOptions.valuePattern = StringUtils.format( pattern0Value1UnitsString,
      SunConstants.VALUE_NUMBERED_PLACEHOLDER, transform.units );

    assert && assert( options.delta === undefined, 'ConcentrationControl sets delta' );
    options.delta = 1; // in view coordinates

    // fill the track with a linear gradient that corresponds to the solution color
    assert && assert( !options.sliderOptions.trackFillEnabled, 'ConcentrationControl sets trackFillEnabled' );
    options.sliderOptions.trackFillEnabled = new LinearGradient( 0, 0, options.sliderOptions.trackSize.width, 0 )
      .addColorStop( 0, solution.colorRange.min )
      .addColorStop( 1, solution.colorRange.max );

    // map concentration value between model and view
    var numberProperty = new DynamicProperty( new Property( solution.concentrationProperty ), {
      bidirectional: true,

      // Necessary because bidirectional:true
      reentrant: true,

      // map from model to view, apply options.interval to model value
      map: value => transform.modelToView( value ),

      // map from view to model, apply options.interval to model value
      inverseMap: value => transform.viewToModel( value )
    } );

    // convert solution's concentration range from model to view
    var numberRange = new Range(
      transform.modelToView( solution.concentrationRange.min ),
      transform.modelToView( solution.concentrationRange.max )
    );

    // ticks at the min and max of the solution's concentration range
    assert && assert( !options.sliderOptions.majorTicks, 'ConcentrationControl sets majorTicks' );
    options.sliderOptions.majorTicks = [];
    [ numberRange.min, numberRange.max ].forEach( function( value ) {
      options.sliderOptions.majorTicks.push( {
        value: value,
        label: new Text( value, { font: TICK_FONT } )
      } );
    } );

    NumberControl.call( this, title, numberProperty, numberRange, options );
  }

  beersLawLab.register( 'ConcentrationControl', ConcentrationControl );

  return inherit( NumberControl, ConcentrationControl );
} );
define( function( require ) {
  'use strict';

  // modules
  var AquaRadioButton = require( 'SUN/AquaRadioButton' );
  var beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  var BLLConstants = require( 'BEERS_LAW_LAB/common/BLLConstants' );
  var BooleanProperty = require( 'AXON/BooleanProperty' );
  var HBox = require( 'SCENERY/nodes/HBox' );
  var HStrut = require( 'SCENERY/nodes/HStrut' );
  var inherit = require( 'PHET_CORE/inherit' );
  var Node = require( 'SCENERY/nodes/Node' );
  var Panel = require( 'SUN/Panel' );
  var PhetFont = require( 'SCENERY_PHET/PhetFont' );
  var Rectangle = require( 'SCENERY/nodes/Rectangle' );
  var StringUtils = require( 'PHETCOMMON/util/StringUtils' );
  var Text = require( 'SCENERY/nodes/Text' );
  var Util = require( 'DOT/Util' );
  var VBox = require( 'SCENERY/nodes/VBox' );
  var WavelengthSlider = require( 'SCENERY_PHET/WavelengthSlider' );

  // strings
  var pattern0LabelString = require( 'string!BEERS_LAW_LAB/pattern.0label' );
  var pattern0Value1UnitsString = require( 'string!BEERS_LAW_LAB/pattern.0value.1units' );
  var presetString = require( 'string!BEERS_LAW_LAB/preset' );
  var unitsNmString = require( 'string!BEERS_LAW_LAB/units.nm' );
  var variableString = require( 'string!BEERS_LAW_LAB/variable' );
  var wavelengthString = require( 'string!BEERS_LAW_LAB/wavelength' );

  /**
   * @param {Property.<BeersLawSolution>} solutionProperty
   * @param {Light} light
   * @param {Tandem} tandem
   * @constructor
   */
  function WavelengthControls( solutionProperty, light, tandem ) {

    // @private is the wavelength variable or fixed?
    this.variableWavelengthProperty = new BooleanProperty( false, {
      tandem: tandem.createTandem( 'variableWavelengthProperty' )
    } );

    var xMargin = 7;
    var yMargin = 3;

    var label = new Text( StringUtils.format( pattern0LabelString, wavelengthString ), {
      font: new PhetFont( 20 ),
      fill: 'black',
      tandem: tandem.createTandem( 'label' )
    } );

    var valueDisplay = new Text( formatWavelength( light.wavelengthProperty.get() ), {
      font: new PhetFont( 20 ),
      fill: 'black',
      y: label.y, // align baselines
      tandem: tandem.createTandem( 'valueDisplay' )
    } );

    var valueBackground = new Rectangle( 0, 0, valueDisplay.width + xMargin + xMargin, valueDisplay.height + yMargin + yMargin, {
      fill: 'white',
      stroke: 'lightGray',
      left: label.right + 10,
      centerY: valueDisplay.centerY
    } );
    valueDisplay.right = valueBackground.right - xMargin; // right aligned

    var valueParent = new Node( {
      children: [ label, valueBackground, valueDisplay ],
      maxWidth: 250 // constrain width for i18n
    } );

    // preset
    var presetRadioButton = new AquaRadioButton( this.variableWavelengthProperty, false,
      new Text( presetString, {
        font: new PhetFont( 18 ),
        fill: 'black'
      } ), {
        radius: BLLConstants.RADIO_BUTTON_RADIUS,
        tandem: tandem.createTandem( 'presetWavelengthRadioButton' )
      } );
    presetRadioButton.touchArea = presetRadioButton.localBounds.dilatedXY( 6, 8 );

    // variable
    var variableRadioButton = new AquaRadioButton( this.variableWavelengthProperty, true,
      new Text( variableString, {
        font: new PhetFont( 18 ),
        fill: 'black'
      } ), {
        radius: BLLConstants.RADIO_BUTTON_RADIUS,
        tandem: tandem.createTandem( 'variableWavelengthRadioButton' )
      } );
    variableRadioButton.touchArea = variableRadioButton.localBounds.dilatedXY( 6, 8 );

    var radioButtons = new HBox( {
      spacing: 18,
      maxWidth: 250, // constrain width for i18n
      children: [ presetRadioButton, variableRadioButton ]
    } );

    var wavelengthSlider = new WavelengthSlider( light.wavelengthProperty, {
      trackWidth: 150,
      trackHeight: 30,
      valueVisible: false,
      tweakersTouchAreaXDilation: 10,
      tweakersTouchAreaYDilation: 10,
      tandem: tandem.createTandem( 'wavelengthSlider' )
    } );

    // rendering order
    var content = new VBox( {
      spacing: 15,
      align: 'left',
      children: [ valueParent, radioButtons, wavelengthSlider ]
    } );

    // add a horizontal strut to prevent width changes
    content.addChild( new HStrut( Math.max( content.width, wavelengthSlider.width ) ) );

    Panel.call( this, content, {
      xMargin: 20,
      yMargin: 20,
      fill: '#F0F0F0',
      stroke: 'gray',
      lineWidth: 1,
      tandem: tandem
    } );

    // When the radio button selection changes...
    this.variableWavelengthProperty.link( function( isVariable ) {

      // add/remove the slider so that the panel resizes
      if ( isVariable ) {
        !content.hasChild( wavelengthSlider ) && content.addChild( wavelengthSlider );
      }
      else {
        content.hasChild( wavelengthSlider ) && content.removeChild( wavelengthSlider );
      }

      if ( !isVariable ) {
        // Set the light to the current solution's lambdaMax wavelength.
        light.wavelengthProperty.set( solutionProperty.get().molarAbsorptivityData.lambdaMax );
      }
    } );

    // sync displayed value with model
    light.wavelengthProperty.link( function( wavelength ) {
      valueDisplay.text = formatWavelength( wavelength );
      valueDisplay.right = valueBackground.right - xMargin; // right aligned
    } );
  }

  beersLawLab.register( 'WavelengthControls', WavelengthControls );

  var formatWavelength = function( wavelength ) {
    return StringUtils.format( pattern0Value1UnitsString, Util.toFixed( wavelength, 0 ), unitsNmString );
  };

  return inherit( Panel, WavelengthControls, {

    // @public
    reset: function() {
      this.variableWavelengthProperty.reset();
    }
  } );
} );
define( function( require ) {
  'use strict';

  // modules
  const beersLawLab = require( 'BEERS_LAW_LAB/beersLawLab' );
  const ComboBox = require( 'SUN/ComboBox' );
  const ComboBoxItem = require( 'SUN/ComboBoxItem' );
  const HBox = require( 'SCENERY/nodes/HBox' );
  const PhetFont = require( 'SCENERY_PHET/PhetFont' );
  const Rectangle = require( 'SCENERY/nodes/Rectangle' );
  const RichText = require( 'SCENERY/nodes/RichText' );
  const StringUtils = require( 'PHETCOMMON/util/StringUtils' );
  const Text = require( 'SCENERY/nodes/Text' );

  // strings
  const pattern0LabelString = require( 'string!BEERS_LAW_LAB/pattern.0label' );
  const solutionString = require( 'string!BEERS_LAW_LAB/solution' );

  class SolutionComboBox extends ComboBox {

    /**
     * @param {BeersLawSolution[]} solutions
     * @param {Property.<BeersLawSolution>} selectedSolutionProperty
     * @param {Node} solutionListParent
     * @param {Tandem} tandem
     * @constructor
     */
    constructor( solutions, selectedSolutionProperty, solutionListParent, tandem ) {

      // 'Solution' label
      const label = new Text( StringUtils.format( pattern0LabelString, solutionString ), { font: new PhetFont( 20 ) } );

      // items
      const items = solutions.map( solution => createItem( solution, tandem ) );

      super( items, selectedSolutionProperty, solutionListParent, {
        labelNode: label,
        listPosition: 'above',
        xMargin: 12,
        yMargin: 12,
        highlightFill: 'rgb( 218, 255, 255 )',
        cornerRadius: 8,
        tandem: tandem
      } );
    }
  }

  beersLawLab.register( 'SolutionComboBox', SolutionComboBox );

  /**
   * Creates a combo box item.
   * @private
   * @param {BeersLawSolution} solution
   * @param {Tandem} tandem
   * @returns {ComboBoxItem}
   */
  const createItem = function( solution, tandem ) {

    const colorSquare = new Rectangle( 0, 0, 20, 20, {
      fill: solution.saturatedColor,
      stroke: solution.saturatedColor.darkerColor()
    } );

    const solutionName = new RichText( solution.getDisplayName(), {
      font: new PhetFont( 20 ),
      tandem: tandem.createTandem( solution.tandemName + 'Text' )
    } );

    const hBox = new HBox( {
      spacing: 5,
      children: [ colorSquare, solutionName ]
    } );

    return new ComboBoxItem( hBox, solution, {
      tandemName: solution.tandemName
    } );
  };

  return SolutionComboBox;
} );