Beispiel #1
0
 getBoundsWithTransform: function( matrix ) {
   var bounds = Bounds2.NOTHING.copy();
   var numSegments = this.segments.length;
   for ( var i = 0; i < numSegments; i++ ) {
     bounds.includeBounds( this.segments[ i ].getBoundsWithTransform( matrix ) );
   }
   return bounds;
 },
Beispiel #2
0
    computeBounds: function() {
      var bounds = Bounds2.NOTHING.copy();

      for ( var i = 0; i < this.halfEdges.length; i++ ) {
        bounds.includeBounds( this.halfEdges[ i ].edge.segment.getBounds() );
      }
      return bounds;
    },
Beispiel #3
0
define( function( require ) {
  'use strict';

  var inherit = require( 'PHET_CORE/inherit' );
  var Bounds2 = require( 'DOT/Bounds2' );
  var namespace = require( 'BAM/namespace' );
  var MoleculeStructure = require( 'BAM/model/MoleculeStructure' );

  var Molecule = namespace.Molecule = function Molecule( numAtoms, numBonds ) {
    MoleculeStructure.call( this, numAtoms || 0, numBonds || 0 );
  };

  inherit( MoleculeStructure, Molecule, {
    // Where the molecule is right now
    get positionBounds() {
      // mutable way of handling this, so we need to make a copy
      var bounds = Bounds2.NOTHING.copy();
      _.each( this.atoms, function( atom ) {
        bounds.includeBounds( atom.positionBounds );
      } );
      return bounds;
    },

    // Where the molecule will end up
    get destinationBounds() {
      // mutable way of handling this, so we need to make a copy
      var bounds = Bounds2.NOTHING.copy();
      _.each( this.atoms, function( atom ) {
        bounds.includeBounds( atom.destinationBounds );
      } );
      return bounds;
    },

    // @param {Vector2}
    shiftDestination: function( delta ) {
      _.each( this.atoms, function( atom ) {
        // TODO: memory: consider alternate mutable form atom.destination.add( delta )
        atom.destination = atom.destination.plus( delta );
      } );
    }
  } );

  return Molecule;
} );
Beispiel #4
0
 getBounds: function() {
   if ( this._bounds === null ) {
     var bounds = Bounds2.NOTHING.copy();
     _.each( this.subpaths, function( subpath ) {
       bounds.includeBounds( subpath.getBounds() );
     } );
     this._bounds = bounds;
   }
   return this._bounds;
 },
Beispiel #5
0
 getOffsetShape: function( distance ) {
   // TODO: abstract away this type of behavior
   var subpaths = [];
   var bounds = Bounds2.NOTHING.copy();
   var subLen = this.subpaths.length;
   for ( var i = 0; i < subLen; i++ ) {
     subpaths.push( this.subpaths[ i ].offset( distance ) );
   }
   subLen = subpaths.length;
   for ( i = 0; i < subLen; i++ ) {
     bounds.includeBounds( subpaths[ i ].bounds );
   }
   return new Shape( subpaths, bounds );
 },
Beispiel #6
0
 getStrokedShape: function( lineStyles ) {
   var subpaths = [];
   var bounds = Bounds2.NOTHING.copy();
   var subLen = this.subpaths.length;
   for ( var i = 0; i < subLen; i++ ) {
     var subpath = this.subpaths[ i ];
     var strokedSubpath = subpath.stroked( lineStyles );
     subpaths = subpaths.concat( strokedSubpath );
   }
   subLen = subpaths.length;
   for ( i = 0; i < subLen; i++ ) {
     bounds.includeBounds( subpaths[ i ].bounds );
   }
   return new Shape( subpaths, bounds );
 },
Beispiel #7
0
    getBounds: function() {
      if ( this._bounds === null ) {
        // acceleration for intersection
        this._bounds = Bounds2.NOTHING.copy().withPoint( this.getStart() )
          .withPoint( this.getEnd() );

        // if the angles are different, check extrema points
        if ( this._startAngle !== this._endAngle ) {
          // check all of the extrema points
          this.includeBoundsAtAngle( 0 );
          this.includeBoundsAtAngle( Math.PI / 2 );
          this.includeBoundsAtAngle( Math.PI );
          this.includeBoundsAtAngle( 3 * Math.PI / 2 );
        }
      }
      return this._bounds;
    },
Beispiel #8
0
    getBoundsWithTransform: function( matrix, lineStyles ) {
      // if we don't need to handle rotation/shear, don't use the extra effort!
      if ( matrix.isAxisAligned() ) {
        return this.getStrokedBounds( lineStyles );
      }

      var bounds = Bounds2.NOTHING.copy();

      var numSubpaths = this.subpaths.length;
      for ( var i = 0; i < numSubpaths; i++ ) {
        var subpath = this.subpaths[ i ];
        bounds.includeBounds( subpath.getBoundsWithTransform( matrix ) );
      }

      if ( lineStyles ) {
        bounds.includeBounds( this.getStrokedShape( lineStyles ).getBoundsWithTransform( matrix ) );
      }

      return bounds;
    },
Beispiel #9
0
    computeExtremePoint: function( transform ) {
      assert && assert( this.halfEdges.length > 0, 'There is no extreme point if we have no edges' );

      // Transform all of the segments into the new transformed coordinate space.
      var transformedSegments = [];
      for ( var i = 0; i < this.halfEdges.length; i++ ) {
        transformedSegments.push( this.halfEdges[ i ].edge.segment.transformed( transform.getMatrix() ) );
      }

      // Find the bounds of the entire transformed boundary
      var transformedBounds = Bounds2.NOTHING.copy();
      for ( i = 0; i < transformedSegments.length; i++ ) {
        transformedBounds.includeBounds( transformedSegments[ i ].getBounds() );
      }

      for ( i = 0; i < transformedSegments.length; i++ ) {
        var segment = transformedSegments[ i ];

        // See if this is one of our potential segments whose bounds have the minimal y value. This indicates at least
        // one point on this segment will be a minimal-y point.
        if ( segment.getBounds().top === transformedBounds.top ) {
          // Pick a point with values that guarantees any point will have a smaller y value.
          var minimalPoint = new Vector2( 0, Number.POSITIVE_INFINITY );

          // Grab parametric t-values for where our segment has extreme points, and adds the end points (which are
          // candidates). One of the points at these values should be our minimal point.
          var tValues = [ 0, 1 ].concat( segment.getInteriorExtremaTs() );
          for ( var j = 0; j < tValues.length; j++ ) {
            var point = segment.positionAt( tValues[ j ] );
            if ( point.y < minimalPoint.y ) {
              minimalPoint = point;
            }
          }

          // Transform this minimal point back into our (non-transformed) boundary's coordinate space.
          return transform.inversePosition2( minimalPoint );
        }
      }

      throw new Error( 'Should not reach here if we have segments' );
    },
Beispiel #10
0
    layoutBuckets: function( buckets ) {
      var usedWidth = 0;
      var bucketBounds = Bounds2.NOTHING.copy(); // considered mutable, used to calculate the center bounds of a bucket AND its atoms

      // lays out all of the buckets from the left to right
      for ( var i = 0; i < buckets.length; i++ ) {
        var bucket = buckets[ i ];
        if ( i !== 0 ) {
          usedWidth += Kit.bucketPadding;
        }

        // include both the bucket's shape and its atoms in our bounds, so we can properly center the group
        bucketBounds.includeBounds( bucket.containerShape.bounds );
        bucket.atoms.forEach( function( atom ) {
          var atomPosition = atom.positionProperty.value;
          bucketBounds.includeBounds( new Bounds2( atomPosition.x - atom.covalentRadius, atomPosition.y - atom.covalentRadius,
            atomPosition.x + atom.covalentRadius, atomPosition.y + atom.covalentRadius ) );
        } );
        bucket.position = new Vector2( usedWidth, 0 );
        usedWidth += bucket.width;
      }

      var kitXCenter = this.availableKitBounds.centerX;
      var kitY = this.availableKitBounds.centerY - bucketBounds.centerY;

      // centers the buckets horizontally within the kit
      buckets.forEach( function( bucket ) {
        // also note: this moves the atoms also!
        bucket.position = new Vector2( bucket.position.x - usedWidth / 2 + kitXCenter + bucket.width / 2, kitY );

        // since changing the bucket's position doesn't change contained atoms!
        // TODO: have the bucket position change do this?
        bucket.atoms.forEach( function( atom ) {
          atom.translatePositionAndDestination( bucket.position );
        } );
      } );
    },
    /**
     * @param {EFACIntroModel} model
     */
    constructor( model ) {
      super();

      // @private
      this.model = model;

      // Create the model-view transform.  The primary units used in the model are meters, so significant zoom is used.
      // The multipliers for the 2nd parameter can be used to adjust where the point (0, 0) in the model appears in the
      // view.
      const modelViewTransform = ModelViewTransform2.createSinglePointScaleInvertedYMapping(
        Vector2.ZERO,
        new Vector2(
          Util.roundSymmetric( this.layoutBounds.width * 0.5 ),
          Util.roundSymmetric( this.layoutBounds.height * 0.85 )
        ),
        EFACConstants.INTRO_MVT_SCALE_FACTOR
      );

      // create nodes that will act as layers in order to create the needed Z-order behavior
      const backLayer = new Node();
      this.addChild( backLayer );
      const beakerBackLayer = new Node();
      this.addChild( beakerBackLayer );
      const beakerGrabLayer = new Node();
      this.addChild( beakerGrabLayer );
      const blockLayer = new Node();
      this.addChild( blockLayer );
      const airLayer = new Node();
      this.addChild( airLayer );
      const leftBurnerEnergyChunkLayer = new EnergyChunkLayer( model.leftBurner.energyChunkList, modelViewTransform );
      this.addChild( leftBurnerEnergyChunkLayer );
      const rightBurnerEnergyChunkLayer = new EnergyChunkLayer( model.rightBurner.energyChunkList, modelViewTransform );
      this.addChild( rightBurnerEnergyChunkLayer );
      const heaterCoolerFrontLayer = new Node();
      this.addChild( heaterCoolerFrontLayer );
      const beakerFrontLayer = new Node();
      this.addChild( beakerFrontLayer );

      // create the lab bench surface image
      const labBenchSurfaceImage = new Image( shelfImage, {
        centerX: modelViewTransform.modelToViewX( 0 ),
        centerY: modelViewTransform.modelToViewY( 0 ) + 10 // slight tweak required due to nature of the image
      } );

      // create a rectangle that will act as the background below the lab bench surface, basically like the side of the
      // bench
      const benchWidth = labBenchSurfaceImage.width * 0.95;
      const benchHeight = 1000; // arbitrary large number, user should never see the bottom of this
      const labBenchSide = new Rectangle(
        labBenchSurfaceImage.centerX - benchWidth / 2,
        labBenchSurfaceImage.centerY,
        benchWidth,
        benchHeight,
        { fill: EFACConstants.CLOCK_CONTROL_BACKGROUND_COLOR }
      );

      // add the bench side and top to the scene - the lab bench side must be behind the bench top
      backLayer.addChild( labBenchSide );
      backLayer.addChild( labBenchSurfaceImage );

      // Determine the vertical center between the lower edge of the top of the bench and the bottom of the canvas, used
      // for layout.
      const centerYBelowSurface = ( this.layoutBounds.height + labBenchSurfaceImage.bottom ) / 2;

      // create the play/pause and step buttons
      const playPauseStepButtonGroup = new PlayPauseStepButtonGroup( model );

      // for testing - option to add fast forward controls
      if ( EFACQueryParameters.showSpeedControls ) {
        const simSpeedButtonGroup = new SimSpeedButtonGroup( model.simSpeedProperty );
        const playPauseStepAndSpeedButtonGroup = new HBox( {
          children: [ playPauseStepButtonGroup, simSpeedButtonGroup ],
          spacing: 25
        } );
        playPauseStepAndSpeedButtonGroup.center = new Vector2( this.layoutBounds.centerX, centerYBelowSurface );
        backLayer.addChild( playPauseStepAndSpeedButtonGroup );
      }
      else {

        // only play/pause and step are being added, so center them below the lab bench
        playPauseStepButtonGroup.center = new Vector2( this.layoutBounds.centerX, centerYBelowSurface );
        backLayer.addChild( playPauseStepButtonGroup );
      }

      // make the heat cool levels equal if they become linked
      model.linkedHeatersProperty.link( linked => {
        if ( linked ) {
          model.leftBurner.heatCoolLevelProperty.value = model.rightBurner.heatCoolLevelProperty.value;
        }
      } );

      // if the heaters are linked, changing the left heater will change the right to match
      model.leftBurner.heatCoolLevelProperty.link( leftHeatCoolAmount => {
        if ( model.linkedHeatersProperty.value ) {
          model.rightBurner.heatCoolLevelProperty.value = leftHeatCoolAmount;
        }
      } );

      // if the heaters are linked, changing the right heater will change the left to match
      model.rightBurner.heatCoolLevelProperty.link( rightHeatCoolAmount => {
        if ( model.linkedHeatersProperty.value ) {
          model.leftBurner.heatCoolLevelProperty.value = rightHeatCoolAmount;
        }
      } );

      // add the burners
      const burnerProjectionAmount = modelViewTransform.modelToViewDeltaX(
        model.leftBurner.getBounds().height * EFACConstants.BURNER_EDGE_TO_HEIGHT_RATIO
      );

      // create left burner node
      const leftBurnerStand = new BurnerStandNode(
        modelViewTransform.modelToViewShape( model.leftBurner.getBounds() ),
        burnerProjectionAmount
      );

      // for testing - option to keep the heater coolers sticky
      const snapToZero = !EFACQueryParameters.stickyBurners;

      // set up left heater-cooler node, front and back are added separately to support layering of energy chunks
      const leftHeaterCoolerBack = new HeaterCoolerBack( model.leftBurner.heatCoolLevelProperty, {
        centerX: modelViewTransform.modelToViewX( model.leftBurner.getBounds().centerX ),
        bottom: modelViewTransform.modelToViewY( model.leftBurner.getBounds().minY ),
        minWidth: leftBurnerStand.width / 1.5,
        maxWidth: leftBurnerStand.width / 1.5
      } );
      const leftHeaterCoolerFront = new HeaterCoolerFront( model.leftBurner.heatCoolLevelProperty, {
        leftTop: leftHeaterCoolerBack.getHeaterFrontPosition(),
        minWidth: leftBurnerStand.width / 1.5,
        maxWidth: leftBurnerStand.width / 1.5,
        thumbSize: new Dimension2( 18, 36 ),
        snapToZero: snapToZero
      } );
      heaterCoolerFrontLayer.addChild( leftHeaterCoolerFront );
      backLayer.addChild( leftHeaterCoolerBack );
      backLayer.addChild( leftBurnerStand );

      // create right burner node
      const rightBurnerStand = new BurnerStandNode(
        modelViewTransform.modelToViewShape( model.rightBurner.getBounds() ),
        burnerProjectionAmount );

      // set up right heater-cooler node
      const rightHeaterCoolerBack = new HeaterCoolerBack( model.rightBurner.heatCoolLevelProperty, {
        centerX: modelViewTransform.modelToViewX( model.rightBurner.getBounds().centerX ),
        bottom: modelViewTransform.modelToViewY( model.rightBurner.getBounds().minY ),
        minWidth: rightBurnerStand.width / 1.5,
        maxWidth: rightBurnerStand.width / 1.5
      } );
      const rightHeaterCoolerFront = new HeaterCoolerFront( model.rightBurner.heatCoolLevelProperty, {
        leftTop: rightHeaterCoolerBack.getHeaterFrontPosition(),
        minWidth: rightBurnerStand.width / 1.5,
        maxWidth: rightBurnerStand.width / 1.5,
        thumbSize: new Dimension2( 18, 36 ),
        snapToZero: snapToZero
      } );
      heaterCoolerFrontLayer.addChild( rightHeaterCoolerFront );
      backLayer.addChild( rightHeaterCoolerBack );
      backLayer.addChild( rightBurnerStand );

      const leftHeaterCoolerDownInputAction = () => {

        // make the right heater-cooler un-pickable if the heaters are linked
        if ( model.linkedHeatersProperty.value ) {
          rightHeaterCoolerFront.interruptSubtreeInput();
          rightHeaterCoolerFront.pickable = false;
        }
      };
      const leftHeaterCoolerUpInputAction = () => {
        rightHeaterCoolerFront.pickable = true;
      };

      // listen to pointer events on the left heater-cooler
      leftHeaterCoolerFront.addInputListener( new DownUpListener( {
        down: leftHeaterCoolerDownInputAction,
        up: leftHeaterCoolerUpInputAction
      } ) );

      // listen to keyboard events on the left heater-cooler
      leftHeaterCoolerFront.addInputListener( {
        keydown: event => {
          if ( KeyboardUtil.isRangeKey( event.domEvent.keyCode ) ) {
            leftHeaterCoolerDownInputAction();
          }
        },
        keyup: event => {
          if ( KeyboardUtil.isRangeKey( event.domEvent.keyCode ) ) {
            leftHeaterCoolerUpInputAction();
          }
        }
      } );

      const rightHeaterCoolerDownInputAction = () => {

        // make the left heater-cooler un-pickable if the heaters are linked
        if ( model.linkedHeatersProperty.value ) {
          leftHeaterCoolerFront.interruptSubtreeInput();
          leftHeaterCoolerFront.pickable = false;
        }
      };
      const rightHeaterCoolerUpInputAction = () => {
        leftHeaterCoolerFront.pickable = true;
      };

      // listen to pointer events on the right heater-cooler
      rightHeaterCoolerFront.addInputListener( new DownUpListener( {
        down: rightHeaterCoolerDownInputAction,
        up: rightHeaterCoolerUpInputAction
      } ) );

      // listen to keyboard events on the right heater-cooler
      rightHeaterCoolerFront.addInputListener( {
        keydown: event => {
          if ( KeyboardUtil.isRangeKey( event.domEvent.keyCode ) ) {
            rightHeaterCoolerDownInputAction();
          }
        },
        keyup: event => {
          if ( KeyboardUtil.isRangeKey( event.domEvent.keyCode ) ) {
            rightHeaterCoolerUpInputAction();
          }
        }
      } );

      // add the air
      airLayer.addChild( new AirNode( model.air, modelViewTransform ) );

      // create a reusable bounds in order to reduce memory allocations
      const reusableConstraintBounds = Bounds2.NOTHING.copy();

      // define a closure that will limit the model element motion based on both view and model constraints
      const constrainMovableElementMotion = ( modelElement, proposedPosition ) => {

        // constrain the model element to stay within the play area
        const viewConstrainedPosition = constrainToPlayArea(
          modelElement,
          proposedPosition,
          this.layoutBounds,
          modelViewTransform,
          reusableConstraintBounds
        );

        // constrain the model element to move legally within the model, which generally means not moving through things
        const viewAndModelConstrainedPosition = model.constrainPosition( modelElement, viewConstrainedPosition );

        // return the position as constrained by both the model and the view
        return viewAndModelConstrainedPosition;
      };

      // add the blocks
      const brickNode = new BlockNode(
        model.brick,
        modelViewTransform,
        constrainMovableElementMotion,
        model.isPlayingProperty,
        { setApproachingEnergyChunkParentNode: airLayer }
      );
      blockLayer.addChild( brickNode );
      const ironBlockNode = new BlockNode(
        model.ironBlock,
        modelViewTransform,
        constrainMovableElementMotion,
        model.isPlayingProperty,
        { setApproachingEnergyChunkParentNode: airLayer }
      );
      blockLayer.addChild( ironBlockNode );
      this.waterBeakerView = new BeakerContainerView(
        model.waterBeaker,
        model,
        modelViewTransform,
        constrainMovableElementMotion,
        { composited: false }
      );
      this.oliveOilBeakerView = new BeakerContainerView(
        model.oliveOilBeaker,
        model,
        modelViewTransform,
        constrainMovableElementMotion,
        {
          label: oliveOilString,
          composited: false
        }
      );

      // add the beakers, which are composed of several pieces
      beakerFrontLayer.addChild( this.waterBeakerView.frontNode );
      beakerFrontLayer.addChild( this.oliveOilBeakerView.frontNode );
      beakerBackLayer.addChild( this.waterBeakerView.backNode );
      beakerBackLayer.addChild( this.oliveOilBeakerView.backNode );
      beakerGrabLayer.addChild( this.waterBeakerView.grabNode );
      beakerGrabLayer.addChild( this.oliveOilBeakerView.grabNode );

      // the sensor layer needs to be above the movable objects
      const sensorLayer = new Node();
      this.addChild( sensorLayer );

      // create and add the temperature and color sensor nodes, which look like a thermometer with a triangle on the side
      const temperatureAndColorSensorNodes = [];
      let sensorNodeWidth = 0;
      let sensorNodeHeight = 0;
      model.temperatureAndColorSensors.forEach( sensor => {
        const temperatureAndColorSensorNode = new TemperatureAndColorSensorNode( sensor, {
          modelViewTransform: modelViewTransform,
          dragBounds: modelViewTransform.viewToModelBounds( this.layoutBounds ),
          draggable: true
        } );

        // sensors need to be behind blocks and beakers while in storage, but in front when them while in use
        sensor.activeProperty.link( active => {
          if ( active ) {
            if ( backLayer.hasChild( temperatureAndColorSensorNode ) ) {
              backLayer.removeChild( temperatureAndColorSensorNode );
            }
            sensorLayer.addChild( temperatureAndColorSensorNode );
          }
          else {
            if ( sensorLayer.hasChild( temperatureAndColorSensorNode ) ) {
              sensorLayer.removeChild( temperatureAndColorSensorNode );
            }
            backLayer.addChild( temperatureAndColorSensorNode );
          }
        } );

        temperatureAndColorSensorNodes.push( temperatureAndColorSensorNode );

        // update the variables that will be used to create the storage area
        sensorNodeHeight = sensorNodeHeight || temperatureAndColorSensorNode.height;
        sensorNodeWidth = sensorNodeWidth || temperatureAndColorSensorNode.width;
      } );

      // create the storage area for the sensors
      const sensorStorageArea = new Rectangle(
        0,
        0,
        sensorNodeWidth * 2,
        sensorNodeHeight * 1.15,
        EFACConstants.CONTROL_PANEL_CORNER_RADIUS,
        EFACConstants.CONTROL_PANEL_CORNER_RADIUS,
        {
          fill: EFACConstants.CONTROL_PANEL_BACKGROUND_COLOR,
          stroke: EFACConstants.CONTROL_PANEL_OUTLINE_STROKE,
          lineWidth: EFACConstants.CONTROL_PANEL_OUTLINE_LINE_WIDTH,
          left: EDGE_INSET,
          top: EDGE_INSET
        }
      );
      backLayer.addChild( sensorStorageArea );
      sensorStorageArea.moveToBack(); // move behind the temperatureAndColorSensorNodes when they are being stored

      // set initial position for sensors in the storage area, hook up listeners to handle interaction with storage area
      const interSensorSpacing = ( sensorStorageArea.width - sensorNodeWidth ) / 2;
      const offsetFromBottomOfStorageArea = 25; // empirically determined
      const sensorNodePositionX = sensorStorageArea.left + interSensorSpacing;
      const sensorPositionInStorageArea = new Vector2(
        modelViewTransform.viewToModelX( sensorNodePositionX ),
        modelViewTransform.viewToModelY( sensorStorageArea.bottom - offsetFromBottomOfStorageArea ) );

      model.temperatureAndColorSensors.forEach( ( sensor, index ) => {

        // add a listener for when the sensor is removed from or returned to the storage area
        sensor.userControlledProperty.link( userControlled => {
          if ( userControlled ) {

            // the user has picked up this sensor
            if ( !sensor.activeProperty.get() ) {

              // The sensor was inactive, which means that it was in the storage area.  In this case, we make it jump
              // a little to cue the user that this is a movable object.
              sensor.positionProperty.set(
                sensor.positionProperty.get().plus( modelViewTransform.viewToModelDelta( SENSOR_JUMP_ON_EXTRACTION ) )
              );

              // activate the sensor
              sensor.activeProperty.set( true );
            }
          }
          else {

            // the user has released this sensor - test if it should go back in the storage area
            const sensorNode = temperatureAndColorSensorNodes[ index ];
            const colorIndicatorBounds = sensorNode.localToParentBounds( sensorNode.colorIndicatorNode.bounds );
            const thermometerBounds = sensorNode.localToParentBounds( sensorNode.thermometerNode.bounds );
            if ( colorIndicatorBounds.intersectsBounds( sensorStorageArea.bounds ) ||
                 thermometerBounds.intersectsBounds( sensorStorageArea.bounds ) ) {
              returnSensorToStorageArea( sensor, true, sensorNode );
            }
          }
        } );
      } );

      /**
       * return a sensor to its initial position in the storage area
       * @param {StickyTemperatureAndColorSensor} sensor
       * @param {Boolean} doAnimation - whether the sensor animates back to the storage area
       * @param {TemperatureAndColorSensorNode} [sensorNode]
       */
      const returnSensorToStorageArea = ( sensor, doAnimation, sensorNode ) => {
        const currentPosition = sensor.positionProperty.get();
        if ( !currentPosition.equals( sensorPositionInStorageArea ) && doAnimation ) {

          // calculate the time needed to get to the destination
          const animationDuration = Math.min(
            sensor.positionProperty.get().distance( sensorPositionInStorageArea ) / SENSOR_ANIMATION_SPEED,
            MAX_SENSOR_ANIMATION_TIME
          );
          const animationOptions = {
            property: sensor.positionProperty,
            to: sensorPositionInStorageArea,
            duration: animationDuration,
            easing: Easing.CUBIC_IN_OUT
          };
          const translateAnimation = new Animation( animationOptions );

          // make the sensor unpickable while it's animating back to the storage area
          translateAnimation.animatingProperty.link( isAnimating => {
            sensorNode && ( sensorNode.pickable = !isAnimating );
          } );
          translateAnimation.start();
        }
        else if ( !currentPosition.equals( sensorPositionInStorageArea ) && !doAnimation ) {

          // set the initial position for this sensor
          sensor.positionProperty.set( sensorPositionInStorageArea );
        }

        // sensors are inactive when in the storage area
        sensor.activeProperty.set( false );
      };

      // function to return all sensors to the storage area
      const returnAllSensorsToStorageArea = () => {
        model.temperatureAndColorSensors.forEach( sensor => {
          returnSensorToStorageArea( sensor, false );
        } );
      };

      // put all of the temperature and color sensors into the storage area as part of initialization process
      returnAllSensorsToStorageArea();

      // create a function that updates the Z-order of the blocks when the user-controlled state changes
      const blockChangeListener = () => {
        if ( model.ironBlock.isStackedUpon( model.brick ) ) {
          brickNode.moveToBack();
        }
        else if ( model.brick.isStackedUpon( model.ironBlock ) ) {
          ironBlockNode.moveToBack();
        }
        else if ( model.ironBlock.getBounds().minX >= model.brick.getBounds().maxX ||
                  model.ironBlock.getBounds().minY >= model.brick.getBounds().maxY ) {
          ironBlockNode.moveToFront();
        }
        else if ( model.brick.getBounds().minX >= model.ironBlock.getBounds().maxX ||
                  model.brick.getBounds().minY >= model.ironBlock.getBounds().maxY ) {
          brickNode.moveToFront();
        }
      };

      // update the Z-order of the blocks whenever the "userControlled" state of either changes
      model.brick.positionProperty.link( blockChangeListener );
      model.ironBlock.positionProperty.link( blockChangeListener );

      // function that updates the Z-order of the beakers when the user-controlled state changes
      const beakerChangeListener = () => {
        if ( model.waterBeaker.getBounds().minY >= model.oliveOilBeaker.getBounds().maxY ) {
          this.waterBeakerView.frontNode.moveToFront();
          this.waterBeakerView.backNode.moveToFront();
          this.waterBeakerView.grabNode.moveToFront();
        }
        else if ( model.oliveOilBeaker.getBounds().minY >= model.waterBeaker.getBounds().maxY ) {
          this.oliveOilBeakerView.frontNode.moveToFront();
          this.oliveOilBeakerView.backNode.moveToFront();
          this.oliveOilBeakerView.grabNode.moveToFront();
        }
      };

      // update the Z-order of the beakers whenever the "userControlled" state of either changes
      model.waterBeaker.positionProperty.link( beakerChangeListener );
      model.oliveOilBeaker.positionProperty.link( beakerChangeListener );

      // Create the control for showing/hiding energy chunks.  The elements of this control are created separately to allow
      // each to be independently scaled. The EnergyChunk that is created here is not going to be used in the
      // simulation, it is only needed for the EnergyChunkNode that is displayed in the show/hide energy chunks toggle.
      const energyChunkNode = new EnergyChunkNode(
        new EnergyChunk( EnergyType.THERMAL, Vector2.ZERO, Vector2.ZERO, new Property( true ) ),
        modelViewTransform
      );
      energyChunkNode.pickable = false;
      const energySymbolsText = new Text( energySymbolsString, {
        font: new PhetFont( 20 ),
        maxWidth: EFACConstants.ENERGY_SYMBOLS_PANEL_TEXT_MAX_WIDTH
      } );
      const showEnergyCheckbox = new Checkbox( new HBox( {
          children: [ energySymbolsText, energyChunkNode ],
          spacing: 5
        } ), model.energyChunksVisibleProperty
      );

      // Create the control for linking/un-linking the heaters
      const flameNode = new Image( flameImage, {
        maxWidth: EFACConstants.ENERGY_CHUNK_WIDTH,
        maxHeight: EFACConstants.ENERGY_CHUNK_WIDTH
      } );
      const linkHeatersText = new Text( linkHeatersString, {
        font: new PhetFont( 20 ),
        maxWidth: EFACConstants.ENERGY_SYMBOLS_PANEL_TEXT_MAX_WIDTH
      } );
      const linkHeatersCheckbox = new Checkbox( new HBox( {
          children: [ linkHeatersText, flameNode ],
          spacing: 5
        } ), model.linkedHeatersProperty
      );

      // Add the checkbox controls
      const controlPanelCheckboxes = new VBox( {
        children: [ showEnergyCheckbox, linkHeatersCheckbox ],
        spacing: 8,
        align: 'left'
      } );
      const controlPanel = new Panel( controlPanelCheckboxes, {
        fill: EFACConstants.CONTROL_PANEL_BACKGROUND_COLOR,
        stroke: EFACConstants.CONTROL_PANEL_OUTLINE_STROKE,
        lineWidth: EFACConstants.CONTROL_PANEL_OUTLINE_LINE_WIDTH,
        cornerRadius: EFACConstants.ENERGY_SYMBOLS_PANEL_CORNER_RADIUS,
        rightTop: new Vector2( this.layoutBounds.width - EDGE_INSET, EDGE_INSET ),
        minWidth: EFACConstants.ENERGY_SYMBOLS_PANEL_MIN_WIDTH
      } );
      backLayer.addChild( controlPanel );

      // create and add the "Reset All" button in the bottom right
      const resetAllButton = new ResetAllButton( {
        listener: () => {
          model.reset();
          returnAllSensorsToStorageArea();
        },
        radius: EFACConstants.RESET_ALL_BUTTON_RADIUS,
        right: this.layoutBounds.maxX - EDGE_INSET,
        centerY: ( labBenchSurfaceImage.bounds.maxY + this.layoutBounds.maxY ) / 2
      } );
      this.addChild( resetAllButton );

      // add a floating sky high above the sim
      const skyNode = new SkyNode(
        this.layoutBounds,
        modelViewTransform.modelToViewY( EFACConstants.INTRO_SCREEN_ENERGY_CHUNK_MAX_TRAVEL_HEIGHT ) + EFACConstants.ENERGY_CHUNK_WIDTH
      );
      this.addChild( skyNode );

      // listen to the manualStepEmitter in the model
      model.manualStepEmitter.addListener( dt => {
        this.manualStep( dt );
      } );

      // helper function the constrains the provided model element's position to the play area
      const constrainToPlayArea = ( modelElement, proposedPosition, playAreaBounds, modelViewTransform, reusuableBounds ) => {
        const viewConstrainedPosition = proposedPosition.copy();

        const elementViewBounds = modelViewTransform.modelToViewBounds(
          modelElement.getCompositeBoundsForPosition( proposedPosition, reusuableBounds )
        );

        // constrain the model element to stay within the play area
        let deltaX = 0;
        let deltaY = 0;
        if ( elementViewBounds.maxX >= playAreaBounds.maxX ) {
          deltaX = modelViewTransform.viewToModelDeltaX( playAreaBounds.maxX - elementViewBounds.maxX );
        }
        else if ( elementViewBounds.minX <= playAreaBounds.minX ) {
          deltaX = modelViewTransform.viewToModelDeltaX( playAreaBounds.minX - elementViewBounds.minX );
        }
        if ( elementViewBounds.minY <= playAreaBounds.minY ) {
          deltaY = modelViewTransform.viewToModelDeltaY( playAreaBounds.minY - elementViewBounds.minY );
        }
        else if ( proposedPosition.y < 0 ) {
          deltaY = -proposedPosition.y;
        }
        viewConstrainedPosition.setXY( viewConstrainedPosition.x + deltaX, viewConstrainedPosition.y + deltaY );

        // return the position as constrained by both the model and the view
        return viewConstrainedPosition;
      };
    }
Beispiel #12
0
  /**
   * @public
   * @constructor
   *
   * @param {Array.<Vector2>} points
   * @param {Array.<Array.<number>>} constraints - Pairs of indices into the points that should be treated as
   *                                               constrained edges.
   * @param {Object} [options]
   */
  function DelaunayTriangulation( points, constraints, options ) {
    options = _.extend( {

    }, options );

    var i;

    // @public {Array.<Vector2>}
    this.points = points;

    // @public {Array.<Array.<number>>}
    this.constraints = constraints;

    // @public {Array.<Triangle>}
    this.triangles = [];

    // @public {Array.<Edge>}
    this.edges = [];

    // @public {Array.<Vertex>}
    this.convexHull = [];

    if ( points.length === 0 ) {
      return;
    }

    // @private {Array.<Vertex>}
    this.vertices = points.map( function( point, index ) {
      assert && assert( point instanceof Vector2 && point.isFinite() );

      return new Vertex( point, index );
    } );

    for ( i = 0; i < this.constraints.length; i++ ) {
      var constraint = this.constraints[ i ];
      var firstIndex = constraint[ 0 ];
      var secondIndex = constraint[ 1 ];
      assert && assert( typeof firstIndex === 'number' && isFinite( firstIndex ) && firstIndex % 1 === 0 && firstIndex >= 0 && firstIndex < points.length );
      assert && assert( typeof secondIndex === 'number' && isFinite( secondIndex ) && secondIndex % 1 === 0 && secondIndex >= 0 && secondIndex < points.length );
      assert && assert( firstIndex !== secondIndex );

      this.vertices[ firstIndex ].constrainedVertices.push( this.vertices[ secondIndex ] );
    }

    this.vertices.sort( DelaunayTriangulation.vertexComparison );

    for ( i = 0; i < this.vertices.length; i++ ) {
      var vertex = this.vertices[ i ];
      vertex.sortedIndex = i;
      for ( var j = vertex.constrainedVertices.length - 1; j >= 0; j-- ) {
        var otherVertex = vertex.constrainedVertices[ j ];

        // If the "other" vertex is later in the sweep-line order, it should have the reference to the earlier vertex,
        // not the other way around.
        if ( otherVertex.sortedIndex === -1 ) {
          otherVertex.constrainedVertices.push( vertex );
          vertex.constrainedVertices.splice( j, 1 );
        }
      }
    }

    // @private {Vertex}
    this.bottomVertex = this.vertices[ 0 ];

    // @private {Array.<Vertex>} - Our initialization will handle our first vertex
    this.remainingVertices = this.vertices.slice( 1 );

    var bounds = Bounds2.NOTHING.copy();
    for ( i = points.length - 1; i >= 0; i-- ) {
      bounds.addPoint( points[ i ] );
    }

    var alpha = 0.4;
    // @private {Vertex} - Fake index -1
    this.artificialMinVertex = new Vertex( new Vector2( bounds.minX - bounds.width * alpha, bounds.minY - bounds.height * alpha ), -1 );
    // @private {Vertex} - Fake index -2
    this.artificialMaxVertex = new Vertex( new Vector2( bounds.maxX + bounds.width * alpha, bounds.minY - bounds.height * alpha ), -2 );

    this.edges.push( new Edge( this.artificialMinVertex, this.artificialMaxVertex ) );
    this.edges.push( new Edge( this.artificialMaxVertex, this.bottomVertex ) );
    this.edges.push( new Edge( this.bottomVertex, this.artificialMinVertex ) );

    // Set up our first (artificial) triangle.
    this.triangles.push( new Triangle( this.artificialMinVertex, this.artificialMaxVertex, this.bottomVertex,
                         this.edges[ 1 ], this.edges[ 2 ], this.edges[ 0 ] ) );

    // @private {Edge|null} - The start of our front (the edges at the front of the sweep-line)
    this.firstFrontEdge = this.edges[ 1 ];
    this.edges[ 1 ].connectAfter( this.edges[ 2 ] );

    // @private {Edge} - The start of our hull (the edges at the back, making up the convex hull)
    this.firstHullEdge = this.edges[ 0 ];
  }
define( require => {
  'use strict';

  // modules
  const Bounds2 = require( 'DOT/Bounds2' );
  const EFACQueryParameters = require( 'ENERGY_FORMS_AND_CHANGES/common/EFACQueryParameters' );
  const energyFormsAndChanges = require( 'ENERGY_FORMS_AND_CHANGES/energyFormsAndChanges' );
  const Vector2 = require( 'DOT/Vector2' );

  // constants
  const OUTSIDE_SLICE_FORCE = 0.01; // In Newtons, empirically determined.

  // width of an energy chunk in the view, used to keep them in bounds
  const ENERGY_CHUNK_VIEW_TO_MODEL_WIDTH = 0.012;

  // parameters that can be adjusted to change the nature of the repulsive redistribution algorithm
  const MAX_TIME_STEP = ( 1 / 60 ) / 3; // in seconds, for algorithm that moves the points, best if a multiple of nominal frame rate
  const ENERGY_CHUNK_MASS = 1E-3; // in kilograms, chosen arbitrarily
  const FLUID_DENSITY = 1000; // in kg / m ^ 3, same as water, used for drag
  const ENERGY_CHUNK_DIAMETER = 1E-3; // in meters, chosen empirically

  // treat energy chunk as if it is shaped like a sphere
  const ENERGY_CHUNK_CROSS_SECTIONAL_AREA = Math.PI * Math.pow( ENERGY_CHUNK_DIAMETER, 2 );
  const DRAG_COEFFICIENT = 500; // unitless, empirically chosen

  // Thresholds for deciding whether or not to perform redistribution. These value should be chosen such that particles
  // spread out, then stop all movement.
  const REDISTRIBUTION_THRESHOLD_ENERGY = 1E-4; // in joules (I think)

  // max number of energy chunk slices that can be handled per call to update positions, adjust as needed
  const MAX_SLICES = 6;

  // max number of energy chunks per slice that can be redistributed per call, adjust as needed
  const MAX_ENERGY_CHUNKS_PER_SLICE = 25;

  // speed used when positioning ECs using deterministic algorithms, in meters per second
  const EC_SPEED_DETERMINISTIC = 0.1;

  // a reusable 2D array of the energy chunks being redistributed, indexed by [sliceNum][ecNum]
  const energyChunks = new Array( MAX_SLICES );

  // a reusable 2D array of the force vectors for the energy chunks, indexed by [sliceNum][ecNum]
  const energyChunkForces = new Array( MAX_SLICES );

  // initialize the reusable arrays
  _.times( MAX_SLICES, function( sliceIndex ) {
    energyChunks[ sliceIndex ] = new Array( MAX_ENERGY_CHUNKS_PER_SLICE );
    energyChunkForces[ sliceIndex ] = new Array( MAX_ENERGY_CHUNKS_PER_SLICE );
    _.times( MAX_ENERGY_CHUNKS_PER_SLICE, function( ecIndex ) {
      energyChunkForces[ sliceIndex ][ ecIndex ] = new Vector2( 0, 0 );
    } );
  } );

  // reusable elements intended to reduce garbage collection and thus improve performance
  const compositeSliceBounds = Bounds2.NOTHING.copy();

  // the main singleton object definition
  const EnergyChunkDistributor = {

    /**
     * Redistribute a set of energy chunks that are contained in energy chunk slices using an algorithm where the
     * chunks are repelled by each other and by the edges of the slice.  The distribution is done taking all nearby
     * slices into account so that the chunks can be distributed in a way that minimizes overlap.
     * @param {EnergyChunkContainerSlice[]} slices - set of slices that contain energy chunks
     * @param {number} dt - change in time
     * @returns {boolean} - a value indicating whether redistribution was done, false can occur if the energy chunks are
     * already well distributed
     * @private
     */
    updatePositionsRepulsive( slices, dt ) {

      // determine a rectangle that bounds all of the slices
      let minX = Number.POSITIVE_INFINITY;
      let minY = Number.POSITIVE_INFINITY;
      let maxX = Number.NEGATIVE_INFINITY;
      let maxY = Number.NEGATIVE_INFINITY;

      // determine the collective bounds of all the slices
      slices.forEach( slice => {
        minX = Math.min( slice.bounds.minX, minX );
        maxX = Math.max( slice.bounds.maxX, maxX );
        minY = Math.min( slice.bounds.minY, minY );
        maxY = Math.max( slice.bounds.maxY, maxY );
      } );
      compositeSliceBounds.setMinMax( minX, minY, maxX, maxY );

      // reusable iterator values and loop variables
      let sliceIndex;
      let ecIndex;
      let slice;

      // initialize the list of energy chunks and forces acting upon them
      let totalNumEnergyChunks = 0;
      for ( sliceIndex = 0; sliceIndex < slices.length; sliceIndex++ ) {

        slice = slices[ sliceIndex ];

        // make sure the pre-allocated arrays for energy chunks and their forces are big enough
        assert && assert(
          slice.energyChunkList.length <= MAX_ENERGY_CHUNKS_PER_SLICE,
          'pre-allocated array too small, please adjust'
        );

        // put each energy chunk on the list of those to be processed and zero out its force vector
        for ( ecIndex = 0; ecIndex < slices[ sliceIndex ].energyChunkList.length; ecIndex++ ) {
          energyChunkForces[ sliceIndex ][ ecIndex ].setXY( 0, 0 );
          energyChunks[ sliceIndex ][ ecIndex ] = slices[ sliceIndex ].energyChunkList.get( ecIndex );
          totalNumEnergyChunks++;
        }
      }

      // make sure that there is actually something to distribute
      if ( totalNumEnergyChunks === 0 ) {
        return false; // nothing to do - bail out
      }

      // Determine the minimum distance that is allowed to be used in the force calculations.  This prevents hitting
      // infinities that can cause run time issues or unreasonably large forces. Denominator empirically determined.
      const minDistance = Math.min( compositeSliceBounds.width, compositeSliceBounds.height ) / 20;

      // The particle repulsion force varies inversely with the density of particles so that we don't end up with hugely
      // repulsive forces that tend to push the particles out of the container.  This formula was made up, and can be
      // adjusted or even replaced if needed.
      const forceConstant = ENERGY_CHUNK_MASS * compositeSliceBounds.width *
                            compositeSliceBounds.height * 0.1 / totalNumEnergyChunks;

      // divide the time step up into the largest value known to work consistently for the algorithm
      let particlesRedistributed = false;
      const numForceCalcSteps = Math.floor( dt / MAX_TIME_STEP );
      const extraTime = dt - numForceCalcSteps * MAX_TIME_STEP;
      for ( let forceCalcStep = 0; forceCalcStep <= numForceCalcSteps; forceCalcStep++ ) {
        const timeStep = forceCalcStep < numForceCalcSteps ? MAX_TIME_STEP : extraTime;

        // update the forces acting on the particle due to its bounding container, other particles, and drag
        for ( sliceIndex = 0; sliceIndex < slices.length; sliceIndex++ ) {
          slice = slices[ sliceIndex ];
          const containerShapeBounds = slice.bounds;

          // determine forces on each energy chunk
          for ( ecIndex = 0; ecIndex < slice.energyChunkList.length; ecIndex++ ) {
            const ec = energyChunks[ sliceIndex ][ ecIndex ];
            if ( containerShapeBounds.containsPoint( ec.positionProperty.value ) ) {

              // compute forces from the edges of the slice boundary
              this.updateEdgeForces(
                ec.positionProperty.value,
                energyChunkForces[ sliceIndex ][ ecIndex ],
                forceConstant,
                minDistance,
                containerShapeBounds
              );

              // compute forces from other energy chunks
              this.updateEnergyChunkForces(
                ec,
                energyChunkForces[ sliceIndex ][ ecIndex ],
                energyChunks,
                slices,
                minDistance,
                forceConstant
              );
            }
            else {

              // point is outside container, move it towards center of shape
              energyChunkForces[ sliceIndex ][ ecIndex ].setXY(
                containerShapeBounds.centerX - ec.positionProperty.value.x,
                containerShapeBounds.centerY - ec.positionProperty.value.y
              ).setMagnitude( OUTSIDE_SLICE_FORCE );
            }
          }
        }

        const maxEnergy = this.updateVelocities( slices, energyChunks, energyChunkForces, timeStep );

        particlesRedistributed = maxEnergy > REDISTRIBUTION_THRESHOLD_ENERGY;

        if ( particlesRedistributed ) {
          this.updateEnergyChunkPositions( slices, timeStep );
        }
      }

      return particlesRedistributed;
    },

    /**
     * compute the force on an energy chunk based on the edges of the container in which it resides
     * @param {Vector2} position
     * @param {Vector2} ecForce
     * @param {number} forceConstant
     * @param {number} minDistance
     * @param {Bounds2} containerBounds
     * @private
     */
    updateEdgeForces: function( position, ecForce, forceConstant, minDistance, containerBounds ) {

      // this should only be called for chunks that are inside a container
      assert && assert( containerBounds.containsPoint( position ) );

      // get the distance to the four different edges
      const distanceFromRightSide = Math.max( containerBounds.maxX - position.x, minDistance );
      const distanceFromBottom = Math.max( position.y - containerBounds.minY, minDistance );
      const distanceFromLeftSide = Math.max( position.x - containerBounds.minX, minDistance );
      const distanceFromTop = Math.max( containerBounds.maxY - position.y, minDistance );

      // apply the forces
      ecForce.addXY( -forceConstant / Math.pow( distanceFromRightSide, 2 ), 0 ); // force from right edge
      ecForce.addXY( 0, forceConstant / Math.pow( distanceFromBottom, 2 ) ); // force from bottom edge
      ecForce.addXY( forceConstant / Math.pow( distanceFromLeftSide, 2 ), 0 ); // force from left edge
      ecForce.addXY( 0, -forceConstant / Math.pow( distanceFromTop, 2 ) ); // force from top edge
    },

    /**
     * update the forces acting on the provided energy chunk due to all the other energy chunks
     * @param {EnergyChunk} ec
     * @param {Vector2} ecForce - the force vector acting on the energy chunk being evaluated
     * @param {EnergyChunk[]} energyChunks
     * @param {EnergyChunkContainerSlice[]} slices
     * @param {number} minDistance
     * @param {number} forceConstant
     * @private
     */
    updateEnergyChunkForces: function( ec, ecForce, energyChunks, slices, minDistance, forceConstant ) {

      // allocate reusable vectors to improve performance
      let vectorFromOther = Vector2.dirtyFromPool();
      const forceFromOther = Vector2.dirtyFromPool();

      // apply the force from each of the other energy chunks, but set some limits on the max force that can be applied
      for ( let sliceIndex = 0; sliceIndex < slices.length; sliceIndex++ ) {
        for ( let ecIndex = 0; ecIndex < slices[ sliceIndex ].energyChunkList.length; ecIndex++ ) {

          const otherEnergyChunk = energyChunks[ sliceIndex ][ ecIndex ];

          // skip self
          if ( otherEnergyChunk === ec ) {
            continue;
          }

          // calculate force vector, but handle cases where too close
          vectorFromOther.setXY(
            ec.positionProperty.value.x - otherEnergyChunk.positionProperty.value.x,
            ec.positionProperty.value.y - otherEnergyChunk.positionProperty.value.y
          );
          if ( vectorFromOther.magnitude < minDistance ) {
            if ( vectorFromOther.setMagnitude( 0 ) ) {

              // create a random vector of min distance
              const randomAngle = phet.joist.random.nextDouble() * Math.PI * 2;
              vectorFromOther.setXY(
                minDistance * Math.cos( randomAngle ),
                minDistance * Math.sin( randomAngle )
              );
            }
            else {
              vectorFromOther = vectorFromOther.setMagnitude( minDistance );
            }
          }

          forceFromOther.setXY( vectorFromOther.x, vectorFromOther.y );
          forceFromOther.setMagnitude( forceConstant / vectorFromOther.magnitudeSquared );

          // add the force to the accumulated forces on this energy chunk
          ecForce.setXY( ecForce.x + forceFromOther.x, ecForce.y + forceFromOther.y );
        }
      }

      // free allocations
      vectorFromOther.freeToPool();
      forceFromOther.freeToPool();
    },

    /**
     * update energy chunk velocities and drag force, returning max total energy of chunks
     * @param  {EnergyChunkContainerSlice[]} slices
     * @param  {EnergyChunk[][]} energyChunks
     * @param  {Vector2[][]} energyChunkForces
     * @param {number} dt - time step
     * @returns {number} - the energy in the most energetic energy chunk
     * @private
     */
    updateVelocities: function( slices, energyChunks, energyChunkForces, dt ) {

      const dragForce = Vector2.dirtyFromPool();
      let energyInMostEnergeticEC = 0;

      // loop through the slices, and then the energy chunks therein, and update their velocities
      for ( let sliceIndex = 0; sliceIndex < slices.length; sliceIndex++ ) {

        const numEnergyChunksInSlice = slices[ sliceIndex ].energyChunkList.length;

        for ( let ecIndex = 0; ecIndex < numEnergyChunksInSlice; ecIndex++ ) {

          // force on this chunk
          const force = energyChunkForces[ sliceIndex ][ ecIndex ];
          assert && assert( !_.isNaN( force.x ) && !_.isNaN( force.y ), 'force contains NaN value' );

          // current velocity
          const energyChunk = energyChunks[ sliceIndex ][ ecIndex ];
          const velocity = energyChunk.velocity;
          assert && assert( !_.isNaN( velocity.x ) && !_.isNaN( velocity.y ), 'velocity contains NaN value' );

          // velocity change is based on the formula v = (F/m)* t, so pre-compute the t/m part for later use
          const forceMultiplier = dt / ENERGY_CHUNK_MASS;

          // calculate drag force using standard drag equation
          const velocityMagnitudeSquared = velocity.magnitudeSquared;
          assert && assert(
          velocityMagnitudeSquared !== Infinity && !_.isNaN( velocityMagnitudeSquared ) && typeof velocityMagnitudeSquared === 'number',
            `velocity^2 is ${velocityMagnitudeSquared}`
          );
          const dragMagnitude = 0.5 * FLUID_DENSITY * DRAG_COEFFICIENT * ENERGY_CHUNK_CROSS_SECTIONAL_AREA * velocityMagnitudeSquared;
          dragForce.setXY( 0, 0 );
          if ( dragMagnitude > 0 ) {
            dragForce.setXY( -velocity.x, -velocity.y );
            dragForce.setMagnitude( dragMagnitude );
          }
          assert && assert( !_.isNaN( dragForce.x ) && !_.isNaN( dragForce.y ), 'dragForce contains NaN value' );

          // update velocity based on the sum of forces acting on the particle
          velocity.addXY( ( force.x + dragForce.x ) * forceMultiplier, ( force.y + dragForce.y ) * forceMultiplier );
          assert && assert( !_.isNaN( velocity.x ) && !_.isNaN( velocity.y ), 'New velocity contains NaN value' );

          // update max energy
          const totalParticleEnergy = 0.5 * ENERGY_CHUNK_MASS * velocityMagnitudeSquared + force.magnitude * Math.PI / 2;
          energyInMostEnergeticEC = Math.max( totalParticleEnergy, energyInMostEnergeticEC );
        }
      }

      // free allocations
      dragForce.freeToPool();

      return energyInMostEnergeticEC;
    },

    /**
     * update the energy chunk positions based on their velocity and a time step
     * @param  {EnergyChunkContainerSlice[]} slices
     * @param  {number} dt - time step in seconds
     */
    updateEnergyChunkPositions: function( slices, dt ) {
      slices.forEach( function( slice ) {
        slice.energyChunkList.forEach( function( ec ) {
          const v = ec.velocity;
          const position = ec.positionProperty.value;
          ec.setPositionXY( position.x + v.x * dt, position.y + v.y * dt );
        } );
      } );
    },

    /**
     * An order-N algorithm for distributing the energy chunks based on an Archimedean spiral.  This was created from
     * first thinking about using concentric circles, then figuring that a spiral is perhaps and easier way to get a
     * similar effect.  Many of the values used were arrived at through trial and error.
     * @param {EnergyChunkContainerSlice[]} slices
     * @param {number} dt - time step
     * @returns {boolean} - true if any energy chunks needed to be moved, false if not
     * @private
     */
    updatePositionsSpiral( slices, dt ) {

      let ecMoved = false;
      const ecDestination = new Vector2( 0, 0 ); // reusable vector to minimize garbage collection

      // loop through each slice, updating the energy chunk positions for each
      for ( let sliceIndex = 0; sliceIndex < slices.length; sliceIndex++ ) {

        const sliceBounds = slices[ sliceIndex ].bounds;
        const sliceCenter = sliceBounds.getCenter();
        const numEnergyChunksInSlice = slices[ sliceIndex ].energyChunkList.length;
        if ( numEnergyChunksInSlice === 0 ) {

          // bail out now if there are no energy chunks to distribute in this slice
          continue;
        }

        // number of turns of the spiral
        const numTurns = 3;

        const maxAngle = numTurns * Math.PI * 2;
        const a = 1 / maxAngle; // the equation for the spiral is generally written as r = a * theta, this is the 'a'

        // Define the angular span over which energy chunks will be placed.  This will grow as the number of energy
        // chunks grows.
        let angularSpan;
        if ( numEnergyChunksInSlice <= 6 ) {
          angularSpan = 2 * Math.PI * ( 1 - 1 / numEnergyChunksInSlice );
        }
        else {
          angularSpan = Math.min( Math.max( numEnergyChunksInSlice / 19 * maxAngle, 2 * Math.PI ), maxAngle );
        }

        // The offset faction defined below controls how weighted the algorithm is towards placing chunks towards the
        // end of the spiral versus the beginning.  We always want to be somewhat weighted towards the end since there
        // is more space at the end, but this gets more important as the number of slices increases because we need to
        // avoid overlap of energy chunks in the middle of the model element.
        const offsetFactor = ( -1 / Math.pow( slices.length, 1.75 ) ) + 1;
        const startAngle = offsetFactor * ( maxAngle - angularSpan );

        // Define a value that will be used to offset the spiral rotation in the different slices so that energy chunks
        // are less likely to line up across slices.
        const spiralAngleOffset = ( 2 * Math.PI ) / slices.length + Math.PI;

        // loop through each energy chunk in this slice and set its position
        for ( let ecIndex = 0; ecIndex < numEnergyChunksInSlice; ecIndex++ ) {
          const ec = slices[ sliceIndex ].energyChunkList.get( ecIndex );

          // calculate the angle to feed into the spiral formula
          let angle;
          if ( numEnergyChunksInSlice <= 1 ) {
            angle = startAngle;
          }
          else {
            angle = startAngle + Math.pow( ecIndex / ( numEnergyChunksInSlice - 1 ), 0.75 ) * angularSpan;
          }

          // calculate a radius value within the "normalized spiral", where the radius is 1 at the max angle
          const normalizedRadius = a * Math.abs( angle );
          assert && assert( normalizedRadius <= 1, 'normalized length must be 1 or smaller' );

          // Rotate the spiral in each set of two slices to minimize overlap between slices.  This works in conjunction
          // with the code that reverses the winding direction below so that the same spiral is never used for any two
          // slices.
          let adjustedAngle = angle + spiralAngleOffset * sliceIndex;

          // Determine the max possible radius for the current angle, which is basically the distance from the center to
          // the closest edge.  This must be reduced a bit to account for the fact that energy chunks have some width in
          // the view.
          const maxRadius = getCenterToEdgeDistance( sliceBounds, adjustedAngle ) - ENERGY_CHUNK_VIEW_TO_MODEL_WIDTH / 2;

          // determine the radius to use as a function of the value from the normalized spiral and the max value
          const radius = maxRadius * normalizedRadius;

          // Reverse the angle on every other slice to get more spread between slices and a more random appearance when
          // chunks are added (because they don't all wind in the same direction).
          if ( sliceIndex % 2 === 0 ) {
            adjustedAngle = -adjustedAngle;
          }

          // calculate the desired position using polar coordinates
          ecDestination.setPolar( radius, adjustedAngle );
          ecDestination.add( sliceCenter );

          // animate the energy chunk towards its destination if it isn't there already
          if ( !ec.positionProperty.value.equals( ecDestination ) ) {
            moveECTowardsDestination( ec, ecDestination, dt );
            ecMoved = true;
          }
        }
      }

      return ecMoved;
    },

    /**
     * Super simple alternative energy chunk distribution algorithm - just puts all energy chunks in center of slice.
     * This is useful for debugging since it positions the chunks as quickly as possible.
     * @param {EnergyChunkContainerSlice[]} slices
     * @param {number} dt - time step
     * @returns {boolean} - true if any energy chunks needed to be moved, false if not
     * @private
     */
    updatePositionsSimple( slices, dt ) {

      let ecMoved = false;
      const ecDestination = new Vector2( 0, 0 ); // reusable vector to minimze garbage collection

      // update the positions of the energy chunks
      slices.forEach( slice => {
        slice.energyChunkList.forEach( energyChunk => {
          ecDestination.setXY( slice.bounds.centerX, slice.bounds.centerY );

          // animate the energy chunk towards its destination if it isn't there already
          if ( !energyChunk.positionProperty.value.equals( ecDestination ) ) {
            moveECTowardsDestination( energyChunk, ecDestination, dt );
            ecMoved = true;
          }
        } );
      } );

      return ecMoved;
    },

    /**
     * Set the algorithm to use in the "updatePositions" method.  This is generally done only during initialization so
     * that users don't see noticeable changes in the energy chunk motion.  The tradeoffs between the different
     * algorithms are generally based on how good it looks and how much computational power it requires.
     * @param {string} algorithmName
     * @public
     */
    setDistributionAlgorithm( algorithmName ) {
      if ( algorithmName === 'repulsive' ) {
        this.updatePositions = this.updatePositionsRepulsive;
      }
      else if ( algorithmName === 'spiral' ) {
        this.updatePositions = this.updatePositionsSpiral;
      }
      else if ( algorithmName === 'simple' ) {
        this.updatePositions = this.updatePositionsSimple;
      }
      else {
        assert && assert( false, 'unknown distribution algorithm specified: ' + algorithmName );
      }
    }
  };

  // Set up the distribution algorithm to use based on query parameters.  If no query parameter is specified, we start
  // with the repulsive algorithm because it looks best, but may move to spiral if poor performance is detected.
  if ( EFACQueryParameters.ecDistribution === null ) {

    // use the repulsive algorithm by default, which looks the best but is also the most computationally expensive
    EnergyChunkDistributor.updatePositions = EnergyChunkDistributor.updatePositionsRepulsive;
  }
  else {
    EnergyChunkDistributor.setDistributionAlgorithm( EFACQueryParameters.ecDistribution );
  }

  /**
   * helper function for moving an energy chunk towards a destination, sets the EC's velocity value
   * @param {EnergyChunk} ec
   * @param {Vector2} destination
   * @param {number} dt - delta time, in seconds
   */
  const moveECTowardsDestination = ( ec, destination, dt ) => {
    const ecPosition = ec.positionProperty.value;
    if ( !ecPosition.equals( destination ) ) {
      if ( ecPosition.distance( destination ) <= EC_SPEED_DETERMINISTIC * dt ) {

        // EC is close enough that it should just go to the destination
        ec.setPosition( destination.copy() );
      }
      else {
        const vectorTowardsDestination = destination.minus( ec.positionProperty.value );
        vectorTowardsDestination.setMagnitude( EC_SPEED_DETERMINISTIC );
        ec.velocity.set( vectorTowardsDestination );
        ec.setPositionXY( ecPosition.x + ec.velocity.x * dt, ecPosition.y + ec.velocity.y * dt );
      }
    }
  };

  /**
   * helper function for getting the distance from the center of the provided bounds to the edge at the given angle
   * @param {Bounds2} bounds
   * @param {number} angle in radians
   * @returns {number}
   */
  const getCenterToEdgeDistance = ( bounds, angle ) => {
    const halfWidth = bounds.width / 2;
    const halfHeight = bounds.height / 2;
    const tangentOfAngle = Math.tan( angle );
    let opposite;
    let adjacent;
    if ( Math.abs( halfHeight / tangentOfAngle ) < halfWidth ) {
      opposite = halfHeight;
      adjacent = opposite / tangentOfAngle;
    }
    else {
      adjacent = halfWidth;
      opposite = halfWidth * tangentOfAngle;
    }

    return Math.sqrt( opposite * opposite + adjacent * adjacent );
  };

  return energyFormsAndChanges.register( 'EnergyChunkDistributor', EnergyChunkDistributor );
} );