<commit_before>// Copyright 2018-2020, University of Colorado Boulder

/**
 * Scenery node that shows a handle, which is made of two parts: the "grip" which is where you would grab it and the
 * "attachment" which are elbow-shaped bars that attach the handle to another surface.
 *
 * @author Chris Klusendorf (PhET Interactive Simulations)
 * @author Sam Reid (PhET Interactive Simulations)
 */

import Matrix3 from '../../dot/js/Matrix3.js';
import Shape from '../../kite/js/Shape.js';
import InstanceRegistry from '../../phet-core/js/documentation/InstanceRegistry.js';
import merge from '../../phet-core/js/merge.js';
import Node from '../../scenery/js/nodes/Node.js';
import Path from '../../scenery/js/nodes/Path.js';
import Color from '../../scenery/js/util/Color.js';
import LinearGradient from '../../scenery/js/util/LinearGradient.js';
import PaintColorProperty from '../../scenery/js/util/PaintColorProperty.js';
import sceneryPhet from './sceneryPhet.js';

// constants
// grip shape
const GRIP_WIDTH = 100;
const GRIP_HEIGHT = 42;
const GRIP_CORNER_RADIUS = GRIP_WIDTH * 0.03;
const GRIP_END_PAD = GRIP_WIDTH * 0.03; // horizontal line between the edge of the grip and the cubic curves
const GRIP_SINGLE_FINGER_INDENT_DEPTH = GRIP_HEIGHT * 0.11;
const GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH = ( GRIP_WIDTH - GRIP_CORNER_RADIUS * 2 - GRIP_END_PAD * 2 ) / 8;
const DEFAULT_GRIP_BASE_COLOR = new Color( 183, 184, 185 );

/**
 * @param {Object} [options]
 * @constructor
 */
class HandleNode extends Node {

  constructor( options ) {

    options = merge( {

      // options for the grip
      gripBaseColor: DEFAULT_GRIP_BASE_COLOR, // {ColorDef} base color of gradient on the grip
      gripStroke: 'black', // {ColorDef} stroke color of the grip
      gripLineWidth: 3,

      // options for the attachment(s)
      attachmentFill: 'gray', // {ColorDef} solid fill color for the attachments
      attachmentStroke: 'black', // {ColorDef} stroke color of the attachments
      attachmentLineWidth: 3,
      hasLeftAttachment: true,
      hasRightAttachment: true

    }, options );

    assert && assert( options.hasLeftAttachment || options.hasRightAttachment, 'at least one attachment is required' );

    // the grip shape begins on the left edge, middle y
    // this is the upper left corner before grip indents start
    const gripShape = new Shape()
      .moveTo( 0, GRIP_HEIGHT / 2 )
      .lineTo( 0, GRIP_CORNER_RADIUS )
      .arc( GRIP_CORNER_RADIUS, GRIP_CORNER_RADIUS, GRIP_CORNER_RADIUS, Math.PI, Math.PI * 1.5, false )
      .lineToRelative( GRIP_END_PAD, 0 );

    // these are the top grip indents
    addGripIndent( gripShape, 1 );
    addGripIndent( gripShape, 1 );
    addGripIndent( gripShape, 1 );
    addGripIndent( gripShape, 1 );

    // this is the whole right edge
    gripShape.lineToRelative( GRIP_END_PAD, 0 )
      .arc( GRIP_WIDTH - GRIP_CORNER_RADIUS, GRIP_CORNER_RADIUS, GRIP_CORNER_RADIUS, Math.PI * 1.5, 0, false )
      .lineToRelative( 0, GRIP_HEIGHT - ( GRIP_CORNER_RADIUS * 2 ) )
      .arc( GRIP_WIDTH - GRIP_CORNER_RADIUS, GRIP_HEIGHT - GRIP_CORNER_RADIUS, GRIP_CORNER_RADIUS, 0, Math.PI / 2, false )
      .lineToRelative( -GRIP_END_PAD, 0 );

    // these are the bottom grip indents
    addGripIndent( gripShape, -1 );
    addGripIndent( gripShape, -1 );
    addGripIndent( gripShape, -1 );
    addGripIndent( gripShape, -1 );

    // this is the lower left hand corner
    gripShape.lineToRelative( -GRIP_END_PAD, 0 )
      .arc( GRIP_CORNER_RADIUS, GRIP_HEIGHT - GRIP_CORNER_RADIUS, GRIP_CORNER_RADIUS, Math.PI / 2, Math.PI, false )
      .lineTo( 0, GRIP_HEIGHT / 2 )
      .close();

    // Use PaintColorProperty so that colors can be updated dynamically via ColorProfile
    const gripBaseColorProperty = new PaintColorProperty( options.gripBaseColor );
    const brighterColorProperty = new PaintColorProperty( gripBaseColorProperty, { luminanceFactor: 0.95 } );
    const darkerColorProperty = new PaintColorProperty( gripBaseColorProperty, { luminanceFactor: -0.35 } );

    // add handle grip shape
    const gripPath = new Path( gripShape, {
      lineWidth: options.gripLineWidth,
      stroke: options.gripStroke,
      fill: new LinearGradient( 0, 0, 0, GRIP_HEIGHT )
        .addColorStop( 0, gripBaseColorProperty )
        .addColorStop( 0.4, brighterColorProperty )
        .addColorStop( 0.7, gripBaseColorProperty )
        .addColorStop( 1.0, darkerColorProperty )
    } );

    assert && assert( !options.hasOwnProperty( 'children' ), 'HandleNode sets children' );
    options = merge( {
      children: [ gripPath ]
    }, options );

    // handle attachment shape vars
    const attachmentShaftWidth = GRIP_HEIGHT * 0.35;
    const attachmentHeight = GRIP_HEIGHT * 1.15;
    const attachmentBaseNubWidth = attachmentShaftWidth * 0.4;
    const attachmentBaseNubHeight = attachmentHeight * 0.2;
    const attachmentMiddleHeight = attachmentHeight * 0.5;
    const attachmentSmallArcRadius = attachmentShaftWidth * 0.5;

    const attachmentOptions = {
      fill: options.attachmentFill,
      stroke: options.attachmentStroke,
      lineWidth: options.attachmentLineWidth,
      lineJoin: 'round',
      top: gripPath.centerY - attachmentShaftWidth / 2
    };

    const leftAttachmentShape = new Shape()

      // Starts at bottom-left and proceeds clockwise
      .moveTo( 0, attachmentHeight )
      .lineToRelative( attachmentBaseNubWidth, -attachmentBaseNubHeight )
      .lineToRelative( 0, -attachmentMiddleHeight )

      // The main arc at the top left of the attachment
      .arc(
        attachmentShaftWidth + attachmentBaseNubWidth + attachmentSmallArcRadius,
        attachmentHeight - attachmentBaseNubHeight - attachmentMiddleHeight,
        attachmentShaftWidth + attachmentSmallArcRadius,
        Math.PI,
        Math.PI * 1.5,
        false
      )
      .lineToRelative( 0, attachmentShaftWidth )

      // This is the smaller arc on the underside of the attachment
      .arc(
        attachmentShaftWidth + attachmentBaseNubWidth + attachmentSmallArcRadius,
        attachmentHeight - attachmentBaseNubHeight - attachmentMiddleHeight,
        attachmentSmallArcRadius,
        Math.PI * 1.5,
        Math.PI,
        true
      )
      .lineToRelative( 0, attachmentMiddleHeight )
      .lineToRelative( attachmentBaseNubWidth, attachmentBaseNubHeight )
      .lineToRelative( -attachmentShaftWidth - ( attachmentBaseNubWidth * 2 ), 0 )
      .close();

    // left attachment
    if ( options.hasLeftAttachment ) {

      const leftAttachmentPath = new Path( leftAttachmentShape, merge( {
        right: gripPath.left + options.gripLineWidth
      }, attachmentOptions ) );

      options.children.unshift( leftAttachmentPath ); // prepend so that attachment is behind grip
    }

    // right attachment, a mirror image of the left
    if ( options.hasRightAttachment ) {

      const rightAttachmentShape = leftAttachmentShape.transformed( Matrix3.scaling( -1, 1 ) );

      // handle right attachment
      const rightAttachmentPath = new Path( rightAttachmentShape, merge( {
        left: gripPath.right - options.gripLineWidth
      }, attachmentOptions ) );

      options.children.unshift( rightAttachmentPath );  // prepend so that attachment is behind grip
    }

    super( options );

    // @private
    this.disposeHandleNode = () => {
      gripBaseColorProperty.dispose();
      brighterColorProperty.dispose();
      darkerColorProperty.dispose();
    };

    // support for binder documentation, stripped out in builds and only runs when ?binder is specified
    assert && phet.chipper.queryParameters.binder && InstanceRegistry.registerDataURL( 'scenery-phet', 'HandleNode', this );
  }

  // @public
  dispose() {
    this.disposeHandleNode();
    HandleNode.prototype.dispose.call( this );
  }
}

sceneryPhet.register( 'HandleNode', HandleNode );

/**
 * Add an "up/down" combination to either the top or bottom of the grip.
 * @param {Shape} shape - the shape to append to
 * @param {number} sign - +1 for top side of grip, -1 for bottom side of grip
 */
function addGripIndent( shape, sign ) {

  // control points for cubic curve shape on grip
  // each single-finger indent is made of two cubic curves that are mirrored over the y-axis
  const controlPoint1X = GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH / 2;
  const controlPoint1Y = 0;
  const controlPoint2X = GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH / 4;
  const controlPoint2Y = GRIP_SINGLE_FINGER_INDENT_DEPTH;

  // this is a grip indent
  shape.cubicCurveToRelative(
    sign * controlPoint1X,
    sign * controlPoint1Y,
    sign * controlPoint2X,
    sign * controlPoint2Y,
    sign * GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH,
    sign * GRIP_SINGLE_FINGER_INDENT_DEPTH )
    .cubicCurveToRelative(
      sign * ( GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH - controlPoint2X ),
      sign * -controlPoint1Y,
      sign * ( GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH - controlPoint1X ),
      sign * -controlPoint2Y,
      sign * GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH,
      sign * -GRIP_SINGLE_FINGER_INDENT_DEPTH );
}

export default HandleNode;<commit_msg>HandleNode.prototype.dispose -> super.dispose, see #600<commit_after>// Copyright 2018-2020, University of Colorado Boulder

/**
 * Scenery node that shows a handle, which is made of two parts: the "grip" which is where you would grab it and the
 * "attachment" which are elbow-shaped bars that attach the handle to another surface.
 *
 * @author Chris Klusendorf (PhET Interactive Simulations)
 * @author Sam Reid (PhET Interactive Simulations)
 */

import Matrix3 from '../../dot/js/Matrix3.js';
import Shape from '../../kite/js/Shape.js';
import InstanceRegistry from '../../phet-core/js/documentation/InstanceRegistry.js';
import merge from '../../phet-core/js/merge.js';
import Node from '../../scenery/js/nodes/Node.js';
import Path from '../../scenery/js/nodes/Path.js';
import Color from '../../scenery/js/util/Color.js';
import LinearGradient from '../../scenery/js/util/LinearGradient.js';
import PaintColorProperty from '../../scenery/js/util/PaintColorProperty.js';
import sceneryPhet from './sceneryPhet.js';

// constants
// grip shape
const GRIP_WIDTH = 100;
const GRIP_HEIGHT = 42;
const GRIP_CORNER_RADIUS = GRIP_WIDTH * 0.03;
const GRIP_END_PAD = GRIP_WIDTH * 0.03; // horizontal line between the edge of the grip and the cubic curves
const GRIP_SINGLE_FINGER_INDENT_DEPTH = GRIP_HEIGHT * 0.11;
const GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH = ( GRIP_WIDTH - GRIP_CORNER_RADIUS * 2 - GRIP_END_PAD * 2 ) / 8;
const DEFAULT_GRIP_BASE_COLOR = new Color( 183, 184, 185 );

/**
 * @param {Object} [options]
 * @constructor
 */
class HandleNode extends Node {

  constructor( options ) {

    options = merge( {

      // options for the grip
      gripBaseColor: DEFAULT_GRIP_BASE_COLOR, // {ColorDef} base color of gradient on the grip
      gripStroke: 'black', // {ColorDef} stroke color of the grip
      gripLineWidth: 3,

      // options for the attachment(s)
      attachmentFill: 'gray', // {ColorDef} solid fill color for the attachments
      attachmentStroke: 'black', // {ColorDef} stroke color of the attachments
      attachmentLineWidth: 3,
      hasLeftAttachment: true,
      hasRightAttachment: true

    }, options );

    assert && assert( options.hasLeftAttachment || options.hasRightAttachment, 'at least one attachment is required' );

    // the grip shape begins on the left edge, middle y
    // this is the upper left corner before grip indents start
    const gripShape = new Shape()
      .moveTo( 0, GRIP_HEIGHT / 2 )
      .lineTo( 0, GRIP_CORNER_RADIUS )
      .arc( GRIP_CORNER_RADIUS, GRIP_CORNER_RADIUS, GRIP_CORNER_RADIUS, Math.PI, Math.PI * 1.5, false )
      .lineToRelative( GRIP_END_PAD, 0 );

    // these are the top grip indents
    addGripIndent( gripShape, 1 );
    addGripIndent( gripShape, 1 );
    addGripIndent( gripShape, 1 );
    addGripIndent( gripShape, 1 );

    // this is the whole right edge
    gripShape.lineToRelative( GRIP_END_PAD, 0 )
      .arc( GRIP_WIDTH - GRIP_CORNER_RADIUS, GRIP_CORNER_RADIUS, GRIP_CORNER_RADIUS, Math.PI * 1.5, 0, false )
      .lineToRelative( 0, GRIP_HEIGHT - ( GRIP_CORNER_RADIUS * 2 ) )
      .arc( GRIP_WIDTH - GRIP_CORNER_RADIUS, GRIP_HEIGHT - GRIP_CORNER_RADIUS, GRIP_CORNER_RADIUS, 0, Math.PI / 2, false )
      .lineToRelative( -GRIP_END_PAD, 0 );

    // these are the bottom grip indents
    addGripIndent( gripShape, -1 );
    addGripIndent( gripShape, -1 );
    addGripIndent( gripShape, -1 );
    addGripIndent( gripShape, -1 );

    // this is the lower left hand corner
    gripShape.lineToRelative( -GRIP_END_PAD, 0 )
      .arc( GRIP_CORNER_RADIUS, GRIP_HEIGHT - GRIP_CORNER_RADIUS, GRIP_CORNER_RADIUS, Math.PI / 2, Math.PI, false )
      .lineTo( 0, GRIP_HEIGHT / 2 )
      .close();

    // Use PaintColorProperty so that colors can be updated dynamically via ColorProfile
    const gripBaseColorProperty = new PaintColorProperty( options.gripBaseColor );
    const brighterColorProperty = new PaintColorProperty( gripBaseColorProperty, { luminanceFactor: 0.95 } );
    const darkerColorProperty = new PaintColorProperty( gripBaseColorProperty, { luminanceFactor: -0.35 } );

    // add handle grip shape
    const gripPath = new Path( gripShape, {
      lineWidth: options.gripLineWidth,
      stroke: options.gripStroke,
      fill: new LinearGradient( 0, 0, 0, GRIP_HEIGHT )
        .addColorStop( 0, gripBaseColorProperty )
        .addColorStop( 0.4, brighterColorProperty )
        .addColorStop( 0.7, gripBaseColorProperty )
        .addColorStop( 1.0, darkerColorProperty )
    } );

    assert && assert( !options.hasOwnProperty( 'children' ), 'HandleNode sets children' );
    options = merge( {
      children: [ gripPath ]
    }, options );

    // handle attachment shape vars
    const attachmentShaftWidth = GRIP_HEIGHT * 0.35;
    const attachmentHeight = GRIP_HEIGHT * 1.15;
    const attachmentBaseNubWidth = attachmentShaftWidth * 0.4;
    const attachmentBaseNubHeight = attachmentHeight * 0.2;
    const attachmentMiddleHeight = attachmentHeight * 0.5;
    const attachmentSmallArcRadius = attachmentShaftWidth * 0.5;

    const attachmentOptions = {
      fill: options.attachmentFill,
      stroke: options.attachmentStroke,
      lineWidth: options.attachmentLineWidth,
      lineJoin: 'round',
      top: gripPath.centerY - attachmentShaftWidth / 2
    };

    const leftAttachmentShape = new Shape()

      // Starts at bottom-left and proceeds clockwise
      .moveTo( 0, attachmentHeight )
      .lineToRelative( attachmentBaseNubWidth, -attachmentBaseNubHeight )
      .lineToRelative( 0, -attachmentMiddleHeight )

      // The main arc at the top left of the attachment
      .arc(
        attachmentShaftWidth + attachmentBaseNubWidth + attachmentSmallArcRadius,
        attachmentHeight - attachmentBaseNubHeight - attachmentMiddleHeight,
        attachmentShaftWidth + attachmentSmallArcRadius,
        Math.PI,
        Math.PI * 1.5,
        false
      )
      .lineToRelative( 0, attachmentShaftWidth )

      // This is the smaller arc on the underside of the attachment
      .arc(
        attachmentShaftWidth + attachmentBaseNubWidth + attachmentSmallArcRadius,
        attachmentHeight - attachmentBaseNubHeight - attachmentMiddleHeight,
        attachmentSmallArcRadius,
        Math.PI * 1.5,
        Math.PI,
        true
      )
      .lineToRelative( 0, attachmentMiddleHeight )
      .lineToRelative( attachmentBaseNubWidth, attachmentBaseNubHeight )
      .lineToRelative( -attachmentShaftWidth - ( attachmentBaseNubWidth * 2 ), 0 )
      .close();

    // left attachment
    if ( options.hasLeftAttachment ) {

      const leftAttachmentPath = new Path( leftAttachmentShape, merge( {
        right: gripPath.left + options.gripLineWidth
      }, attachmentOptions ) );

      options.children.unshift( leftAttachmentPath ); // prepend so that attachment is behind grip
    }

    // right attachment, a mirror image of the left
    if ( options.hasRightAttachment ) {

      const rightAttachmentShape = leftAttachmentShape.transformed( Matrix3.scaling( -1, 1 ) );

      // handle right attachment
      const rightAttachmentPath = new Path( rightAttachmentShape, merge( {
        left: gripPath.right - options.gripLineWidth
      }, attachmentOptions ) );

      options.children.unshift( rightAttachmentPath );  // prepend so that attachment is behind grip
    }

    super( options );

    // @private
    this.disposeHandleNode = () => {
      gripBaseColorProperty.dispose();
      brighterColorProperty.dispose();
      darkerColorProperty.dispose();
    };

    // support for binder documentation, stripped out in builds and only runs when ?binder is specified
    assert && phet.chipper.queryParameters.binder && InstanceRegistry.registerDataURL( 'scenery-phet', 'HandleNode', this );
  }

  // @public
  dispose() {
    this.disposeHandleNode();
    super.dispose();
  }
}

sceneryPhet.register( 'HandleNode', HandleNode );

/**
 * Add an "up/down" combination to either the top or bottom of the grip.
 * @param {Shape} shape - the shape to append to
 * @param {number} sign - +1 for top side of grip, -1 for bottom side of grip
 */
function addGripIndent( shape, sign ) {

  // control points for cubic curve shape on grip
  // each single-finger indent is made of two cubic curves that are mirrored over the y-axis
  const controlPoint1X = GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH / 2;
  const controlPoint1Y = 0;
  const controlPoint2X = GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH / 4;
  const controlPoint2Y = GRIP_SINGLE_FINGER_INDENT_DEPTH;

  // this is a grip indent
  shape.cubicCurveToRelative(
    sign * controlPoint1X,
    sign * controlPoint1Y,
    sign * controlPoint2X,
    sign * controlPoint2Y,
    sign * GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH,
    sign * GRIP_SINGLE_FINGER_INDENT_DEPTH )
    .cubicCurveToRelative(
      sign * ( GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH - controlPoint2X ),
      sign * -controlPoint1Y,
      sign * ( GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH - controlPoint1X ),
      sign * -controlPoint2Y,
      sign * GRIP_SINGLE_FINGER_INDENT_HALF_WIDTH,
      sign * -GRIP_SINGLE_FINGER_INDENT_DEPTH );
}

export default HandleNode;<|endoftext|>


<commit_before>// Copyright 2002-2013, University of Colorado Boulder

/**
 * Shows the home screen for a multi-screen simulation, which lets the user see all of the screens and select one.
 *
 * @author Sam Reid
 */
define( function( require ) {
  'use strict';

  var PhetButton = require( 'JOIST/PhetButton' );
  var Node = require( 'SCENERY/nodes/Node' );
  var HBox = require( 'SCENERY/nodes/HBox' );
  var VBox = require( 'SCENERY/nodes/VBox' );
  var Text = require( 'SCENERY/nodes/Text' );
  var Shape = require( 'KITE/Shape' );
  var inherit = require( 'PHET_CORE/inherit' );
  var platform = require( 'PHET_CORE/platform' );
  var ScreenView = require( 'JOIST/ScreenView' );
  var Frame = require( 'JOIST/Frame' );
  var Property = require( 'AXON/Property' );
  var FullScreenButton = require( 'JOIST/FullScreenButton' );
  var phetLogo = require( 'image!JOIST/phet-logo-short.svg' );
  var PhetFont = require( 'SCENERY_PHET/PhetFont' );

  var HEIGHT = 70;

  function HomeScreen( sim ) {
    var homeScreen = this;

    //Rendering in SVG seems to solve the problem that the home screen consumes 100% disk and crashes, see https://github.com/phetsims/joist/issues/17
    //Also makes it more responsive (and crisper on retina displays)
    //Renderer must be specified here because the node is added directly to the scene (instead of to some other node that already has svg renderer
    ScreenView.call( this, {renderer: 'svg'} );

    this.backgroundColor = 'black';

    //iPad doesn't support Century Gothic, so fall back to Futura, see http://wordpress.org/support/topic/font-not-working-on-ipad-browser
    var title = new Text( sim.name, {
      font: new PhetFont( {size: 52, family: 'Century Gothic, Futura' } ),
      fill: 'white',
      y: 110,
      centerX: this.layoutBounds.width / 2} );
    this.addChild( title );

    //Keep track of which screen is highlighted so the same screen can remain highlighted even if nodes are replaced (say when one grows larger or smaller)
    var highlightedIndex = new Property( -1 );

    var screenChildren = _.map( sim.screens, function( screen ) {
      var index = sim.screens.indexOf( screen );
      var largeIcon = new Node( {children: [screen.homeScreenIcon], scale: HEIGHT / screen.homeScreenIcon.height * 2} );
      var frame = new Frame( largeIcon );

      highlightedIndex.link( function( highlightedIndex ) { frame.setHighlighted( highlightedIndex === index ); } );

      var largeIconWithFrame = new Node( {children: [ frame, largeIcon]} );
      var largeText = new Text( screen.name, { font: new PhetFont( 42 ), fill: 'yellow'} );

      //Shrink the text if it goes beyond the edge of the image
      if ( largeText.width > largeIconWithFrame.width ) {
        largeText.scale( largeIconWithFrame.width / largeText.width );
      }
      var large = new VBox( {
        //Don't resize the VBox or it will shift down when the border becomes thicker
        resize: false,

        cursor: 'pointer', children: [
          largeIconWithFrame,
          largeText
        ]} );

      //TODO: Switch to buttonListener, but make sure you test it because on 7/17/2013 there is a problem where ButtonListener won't fire if a node has appeared under the pointer
      large.addInputListener( {
        down: function() {
          sim.simModel.showHomeScreen = false;
          highlightedIndex.value = -1;
        }
      } );

      var smallIcon = new Node( {opacity: 0.5, children: [screen.homeScreenIcon], scale: sim.screens.length === 4 ? HEIGHT / screen.homeScreenIcon.height :
                                                                                sim.screens.length === 3 ? 1.25 * HEIGHT / screen.homeScreenIcon.height :
                                                                                sim.screens.length === 2 ? 1.75 * HEIGHT / screen.homeScreenIcon.height :
                                                                                HEIGHT / screen.homeScreenIcon.height} );
      var smallText = new Text( screen.name, { font: new PhetFont( 18 ), fill: 'gray'} );

      //Shrink the text if it goes beyond the edge of the image
      if ( smallText.width > smallIcon.width ) {
        smallText.scale( smallIcon.width / smallText.width );
      }

      var small = new VBox( {spacing: 3, cursor: 'pointer', children: [
        smallIcon,
        smallText
      ]} );
      small.mouseArea = small.touchArea = Shape.bounds( small.bounds ); //cover the gap in the vbox
      small.addInputListener( {
        down: function() { sim.simModel.screenIndex = index; },

        //On the home screen if you touch an inactive screen thumbnail, it grows.  If then without lifting your finger you swipe over
        // to the next thumbnail, that one would grow.
        over: function( event ) {
          if ( !event.pointer.isMouse ) {
            sim.simModel.screenIndex = index;
          }
        }
      } );

      var highlightListener = {
        over: function( event ) {
          if ( event.pointer.isMouse ) {
            highlightedIndex.value = index;

            //TODO: use named children instead of child indices?
            small.children[0].opacity = 1;
            small.children[1].fill = 'white';
          }
        },
        out: function( event ) {
          if ( event.pointer.isMouse ) {
            highlightedIndex.value = -1;
            small.children[0].opacity = 0.5;
            small.children[1].fill = 'gray';
          }
        }
      };
      small.addInputListener( highlightListener );

      large.addInputListener( highlightListener );
      large.mouseArea = large.touchArea = Shape.bounds( large.bounds ); //cover the gap in the vbox

      //TODO: Add accessibility peers
      //      screenChild.addPeer( '<input type="button" aria-label="' + screenChild.screen.name + '">', {click: function() {
//        var screen = screenChild.screen;
//        if ( sim.simModel.screenIndex === screen.index ) {
//          sim.simModel.showHomeScreen = false;
//        }
//        else {
//          sim.simModel.screenIndex = screen.index;
//        }
//      }} );
//    } );

      return {screen: screen, small: small, large: large, index: index};
    } );

    var center = new Node( {y: 170} );
    homeScreen.addChild( center );
    sim.simModel.screenIndexProperty.link( function( screenIndex ) {

      //Space the icons out more if there are fewer, so they will be spaced nicely
      //Cannot have only 1 screen because for 1-screen sims there is no home screen.
      var spacing = sim.screens.length === 2 ? 60 :
                    sim.screens.length === 3 ? 60 :
                    33;

      var icons = _.map( screenChildren, function( screenChild ) {return screenChild.index === screenIndex ? screenChild.large : screenChild.small;} );
      center.children = [new HBox( {spacing: spacing, children: icons, align: 'top'} )];
      center.centerX = homeScreen.layoutBounds.width / 2;
    } );

    //Only show the full screen button on supported platforms
    var showFullScreenButton = !platform.android && !platform.mobileSafari && !platform.ie; // might work on IE11 in the future
    if ( showFullScreenButton && false ) {
      var fullScreenButton = new FullScreenButton();
      var phetButton = new PhetButton( sim );
      this.addChild( new HBox( {spacing: 10, children: [fullScreenButton, phetButton], right: this.layoutBounds.maxX - 5, bottom: this.layoutBounds.maxY - 5} ) );
    }
    else {
      this.addChild( new PhetButton( sim, false, {
        phetLogo: phetLogo,
        phetLogoScale: 0.4,
        optionsButtonVerticalMargin: 6} ).mutate( {
          right: this.layoutBounds.maxX - 5,
          bottom: this.layoutBounds.maxY - 5
        } ) );
    }
  }

  return inherit( ScreenView, HomeScreen );
} );
<commit_msg>Added resize function to HBox and factored out shared code to AbstractBox, see https://github.com/phetsims/scenery/issues/116<commit_after>// Copyright 2002-2013, University of Colorado Boulder

/**
 * Shows the home screen for a multi-screen simulation, which lets the user see all of the screens and select one.
 *
 * @author Sam Reid
 */
define( function( require ) {
  'use strict';

  var PhetButton = require( 'JOIST/PhetButton' );
  var Node = require( 'SCENERY/nodes/Node' );
  var HBox = require( 'SCENERY/nodes/HBox' );
  var VBox = require( 'SCENERY/nodes/VBox' );
  var Text = require( 'SCENERY/nodes/Text' );
  var Shape = require( 'KITE/Shape' );
  var inherit = require( 'PHET_CORE/inherit' );
  var platform = require( 'PHET_CORE/platform' );
  var ScreenView = require( 'JOIST/ScreenView' );
  var Frame = require( 'JOIST/Frame' );
  var Property = require( 'AXON/Property' );
  var FullScreenButton = require( 'JOIST/FullScreenButton' );
  var phetLogo = require( 'image!JOIST/phet-logo-short.svg' );
  var PhetFont = require( 'SCENERY_PHET/PhetFont' );

  var HEIGHT = 70;

  function HomeScreen( sim ) {
    var homeScreen = this;

    //Rendering in SVG seems to solve the problem that the home screen consumes 100% disk and crashes, see https://github.com/phetsims/joist/issues/17
    //Also makes it more responsive (and crisper on retina displays)
    //Renderer must be specified here because the node is added directly to the scene (instead of to some other node that already has svg renderer
    ScreenView.call( this, {renderer: 'svg'} );

    this.backgroundColor = 'black';

    //iPad doesn't support Century Gothic, so fall back to Futura, see http://wordpress.org/support/topic/font-not-working-on-ipad-browser
    var title = new Text( sim.name, {
      font: new PhetFont( {size: 52, family: 'Century Gothic, Futura' } ),
      fill: 'white',
      y: 110,
      centerX: this.layoutBounds.width / 2} );
    this.addChild( title );

    //Keep track of which screen is highlighted so the same screen can remain highlighted even if nodes are replaced (say when one grows larger or smaller)
    var highlightedIndex = new Property( -1 );

    var screenChildren = _.map( sim.screens, function( screen ) {
      var index = sim.screens.indexOf( screen );
      var largeIcon = new Node( {children: [screen.homeScreenIcon], scale: HEIGHT / screen.homeScreenIcon.height * 2} );
      var frame = new Frame( largeIcon );

      highlightedIndex.link( function( highlightedIndex ) { frame.setHighlighted( highlightedIndex === index ); } );

      var largeIconWithFrame = new Node( {children: [ frame, largeIcon]} );
      var largeText = new Text( screen.name, { font: new PhetFont( 42 ), fill: 'yellow'} );

      //Shrink the text if it goes beyond the edge of the image
      if ( largeText.width > largeIconWithFrame.width ) {
        largeText.scale( largeIconWithFrame.width / largeText.width );
      }
      var large = new VBox( {
        //Don't resize the VBox or it will shift down when the border becomes thicker
        resize: false,

        cursor: 'pointer', children: [
          largeIconWithFrame,
          largeText
        ]} );

      //TODO: Switch to buttonListener, but make sure you test it because on 7/17/2013 there is a problem where ButtonListener won't fire if a node has appeared under the pointer
      large.addInputListener( {
        down: function() {
          sim.simModel.showHomeScreen = false;
          highlightedIndex.value = -1;
        }
      } );

      var smallIcon = new Node( {opacity: 0.5, children: [screen.homeScreenIcon], scale: sim.screens.length === 4 ? HEIGHT / screen.homeScreenIcon.height :
                                                                                sim.screens.length === 3 ? 1.25 * HEIGHT / screen.homeScreenIcon.height :
                                                                                sim.screens.length === 2 ? 1.75 * HEIGHT / screen.homeScreenIcon.height :
                                                                                HEIGHT / screen.homeScreenIcon.height} );
      var smallText = new Text( screen.name, { font: new PhetFont( 18 ), fill: 'gray'} );

      //Shrink the text if it goes beyond the edge of the image
      if ( smallText.width > smallIcon.width ) {
        smallText.scale( smallIcon.width / smallText.width );
      }

      var small = new VBox( {spacing: 3, cursor: 'pointer', children: [
        smallIcon,
        smallText
      ]} );
      small.mouseArea = small.touchArea = Shape.bounds( small.bounds ); //cover the gap in the vbox
      small.addInputListener( {
        down: function() { sim.simModel.screenIndex = index; },

        //On the home screen if you touch an inactive screen thumbnail, it grows.  If then without lifting your finger you swipe over
        // to the next thumbnail, that one would grow.
        over: function( event ) {
          if ( !event.pointer.isMouse ) {
            sim.simModel.screenIndex = index;
          }
        }
      } );

      var highlightListener = {
        over: function( event ) {
          if ( event.pointer.isMouse ) {
            highlightedIndex.value = index;

            //TODO: use named children instead of child indices?
            small.children[0].opacity = 1;
            small.children[1].fill = 'white';
          }
        },
        out: function( event ) {
          if ( event.pointer.isMouse ) {
            highlightedIndex.value = -1;
            small.children[0].opacity = 0.5;
            small.children[1].fill = 'gray';
          }
        }
      };
      small.addInputListener( highlightListener );

      large.addInputListener( highlightListener );
      large.mouseArea = large.touchArea = Shape.bounds( large.bounds ); //cover the gap in the vbox

      //TODO: Add accessibility peers
      //      screenChild.addPeer( '<input type="button" aria-label="' + screenChild.screen.name + '">', {click: function() {
//        var screen = screenChild.screen;
//        if ( sim.simModel.screenIndex === screen.index ) {
//          sim.simModel.showHomeScreen = false;
//        }
//        else {
//          sim.simModel.screenIndex = screen.index;
//        }
//      }} );
//    } );

      return {screen: screen, small: small, large: large, index: index};
    } );

    var center = new Node( {y: 170} );
    homeScreen.addChild( center );
    sim.simModel.screenIndexProperty.link( function( screenIndex ) {

      //Space the icons out more if there are fewer, so they will be spaced nicely
      //Cannot have only 1 screen because for 1-screen sims there is no home screen.
      var spacing = sim.screens.length === 2 ? 60 :
                    sim.screens.length === 3 ? 60 :
                    33;

      var icons = _.map( screenChildren, function( screenChild ) {return screenChild.index === screenIndex ? screenChild.large : screenChild.small;} );
      center.children = [new HBox( {spacing: spacing, children: icons, align: 'top', resize: false} )];
      center.centerX = homeScreen.layoutBounds.width / 2;
    } );

    //Only show the full screen button on supported platforms
    var showFullScreenButton = !platform.android && !platform.mobileSafari && !platform.ie; // might work on IE11 in the future
    if ( showFullScreenButton && false ) {
      var fullScreenButton = new FullScreenButton();
      var phetButton = new PhetButton( sim );
      this.addChild( new HBox( {spacing: 10, children: [fullScreenButton, phetButton], right: this.layoutBounds.maxX - 5, bottom: this.layoutBounds.maxY - 5} ) );
    }
    else {
      this.addChild( new PhetButton( sim, false, {
        phetLogo: phetLogo,
        phetLogoScale: 0.4,
        optionsButtonVerticalMargin: 6} ).mutate( {
          right: this.layoutBounds.maxX - 5,
          bottom: this.layoutBounds.maxY - 5
        } ) );
    }
  }

  return inherit( ScreenView, HomeScreen );
} );
<|endoftext|>


<commit_before>import React, { Component } from 'react'
import PropTypes from 'prop-types'
import styles from './Carousel.css'

import { resize } from '../../utils/helpers'

import leftChevron from './chevron_left.svg'
import rightChevron from './chevron_right.svg'

export default class Carousel extends Component {
   static displayName = 'Carousel'

   static propTypes = {
      showIndicators: PropTypes.bool,
      infiniteLoop: PropTypes.bool,
      legendClassName: PropTypes.string,
      wrapperClassName: PropTypes.string,
      cssEase: PropTypes.string,
      animationType: PropTypes.oneOf(['fade', 'slide']),
      selectedItem: PropTypes.number,
      height: PropTypes.oneOfType(PropTypes.string, PropTypes.number),
      autoPlay: PropTypes.bool,
      stopOnHover: PropTypes.bool,
      intervalDuration: PropTypes.number,
      transitionDuration: PropTypes.number,
      children: PropTypes.node,
      redirectCallback: PropTypes.func,
      onCarouselChange: PropTypes.func,
      onCarouselItemClick: PropTypes.func,
      onCarouselMouseEnter: PropTypes.func,
      onCarouselMouseLeave: PropTypes.func,
      initializedCarousel: PropTypes.func,
      imagesLoaded: PropTypes.func,
   }

   static defaultProps = {
      showIndicators: true,
      infinite: true,
      showArrows: true,
      infiniteLoop: true,
      legendClassName: null,
      wrapperClassName: null,
      cssEase: 'ease',
      animationType: 'slide',
      selectedItem: 0,
      height: 0,
      autoPlay: false,
      stopOnHover: false,
      intervalDuration: 3500,
      transitionDuration: 800,
      redirectCallback: null,
      onCarouselChange: () => {},
      onCarouselMouseEnter: () => {},
      onCarouselMouseLeave: () => {},
      onCarouselItemClick: () => {},
      initializedCarousel: () => {},
      imageLoaded: () => {},
   }

   constructor(props) {
      super(props)
      this.autoPlayTimer = null
      this.changeTimer = null

      this.state = {
         isMouseEntered: false,
         currentPosition: props.selectedItem,
         lastPosition: null,
         carouselItems: [],
         cssAnimation: {},
         translate3d: null,
         initialized: false,
         fromChildren: false,
         disabled: false,
         previousArrowHover: false,
         nextArrowHover: false,
         selectedIndicator: null,
         ongoingTouches: [],
      }
   }

   componentDidMount() {
      const { children, autoPlay } = this.props

      if (children == null || children.length === 0) {
         return null
      }

      this.setupCarousel()
      resize.add(() => this.setupCarousel())

      if (autoPlay) {
         this.setupAutoPlay()
      }
   }

   componentDidUpdate() {
      if (!this.state.initialized) {
         this.setupCarousel()
      }

      if (this.props.autoPlay) {
         this.resetAutoPlay()
      }
   }

   componentWillUnmount() {
      clearTimeout(this.changeTimer)
   }

   setupCarousel() {
      const { children } = this.props
      const { currentPosition } = this.state

      const itemWidth = this.sliderNode.getBoundingClientRect().width
      const currentIndex = Math.min(
         currentPosition ? Math.abs(Math.ceil(currentPosition)) : 0,
         children.length - 1
      )

      this.setState(
         {
            currentPosition: currentIndex,
            initialized: true,
            carouselItems: children,
            clones: this.cloneItems(children),
            translate3d: -itemWidth * (1 + currentIndex),
            itemWidth,
         },
         () => this.props.initializedCarousel(true)
      )
   }

   cloneItems = children => {
      const firstChild = children.slice(0, 1)
      const lastChild = children.slice(children.length - 1)

      return [...lastChild, ...children, ...firstChild]
   }

   setupCarouselItems() {
      const items = this.props.children
      this.setState({
         lastPosition: items.length,
         carouselItems: items,
         fromChildren: true,
      })
   }

   setupAutoPlay() {
      this.autoPlay()
      const carouselWrapper = this.carouselWrapper

      if (this.props.stopOnHover && carouselWrapper) {
         // Stop mobile propagation
         // Required so touch/tap events don't cause mouseEnter/mouseLeave to fire
         const mobileTouch = (function isTouch() {
            try {
               document.createEvent('TouchEvent')
               return true
            } catch (e) {
               return false
            }
         })()

         if (!mobileTouch) {
            carouselWrapper.addEventListener(
               'mouseenter',
               ev => {
                  this.stopOnHover()
               },
               false
            )

            carouselWrapper.addEventListener('mouseleave', ev => {
               this.startOnHoverLeave()
            })
         }
      }
   }

   autoPlay() {
      this.autoPlayTimer = setTimeout(() => {
         this.increment()
      }, this.props.intervalDuration)
   }

   clearAutoPlay() {
      clearTimeout(this.autoPlayTimer)
      clearTimeout(this.calcCheckTimer)
      clearTimeout(this.changeTimer)
   }

   resetAutoPlay() {
      this.clearAutoPlay()
      this.autoPlay()
   }
   copyTouch(touch) {
      return {
         identifier: touch.identifier,
         pageX: touch.pageX,
         pageY: touch.pageY,
      }
   }
   handleTouchStart() {
      this.clearAutoPlay()
   }
   handleTouchMove(evt) {
      var touches = evt.changedTouches
      const lastTouch = this.state.ongoingTouches[
         this.state.ongoingTouches.length - 1
      ]
      this.setState({
         ...this.state,
         ongoingTouches: [
            ...this.state.ongoingTouches,
            this.copyTouch(touches[0]),
         ],
      })
   }
   handleTouchEnd() {
      this.resetAutoPlay()
      const touches = this.state.ongoingTouches.map(touch => {
         return touch.pageX
      })
      const change = touches[touches.length - 1] - touches[0]
      if (change > 0) {
         this.decrement()
      } else {
         this.increment()
      }
   }
   stopOnHover() {
      this.setState({
         isMouseEntered: true,
      })
      this.props.onCarouselMouseEnter(
         Date.now(),
         this.state.carouselItems[this.state.currentPosition].itemId
      )
      if (this.props.autoPlay) {
         this.clearAutoPlay()
      }
   }

   startOnHoverLeave() {
      this.setState({
         isMouseEntered: false,
      })
      this.props.onCarouselMouseLeave(
         Date.now(),
         this.state.carouselItems[this.state.currentPosition].itemId
      )
      if (this.props.autoPlay) {
         this.autoPlay()
      }
   }

   decrement = () => {
      this.moveTo(this.state.currentPosition - 1)
   }

   increment = () => {
      this.moveTo(this.state.currentPosition + 1)
   }

   moveTo = index => {
      const { itemWidth, isMouseEntered, carouselItems, disabled } = this.state

      const translate = (index + 1) * itemWidth

      this.setState(
         {
            disabled: true,
            currentPosition: index,
            translate3d: -translate,
            cssAnimation: {
               transition: `transform ${this.props.transitionDuration}ms ${
                  this.props.cssEase
               }`,
            },
         },
         this.recalculateChecker
      )

      // don't reset auto play when stop on hover is enabled, doing so will trigger a call to auto play more than once
      // and will result in the interval function not being cleared correctly.
      if (this.props.autoPlay && !isMouseEntered) {
         this.resetAutoPlay()
      }
   }

   recalculateChecker = () => {
      const { currentPosition, carouselItems } = this.state
      const recalc =
         currentPosition < 0 || currentPosition >= carouselItems.length

      window.clearTimeout()
      window.setTimeout(() => {
         recalc ? this.calculateSliderPos() : this.onSlideChange()
      }, this.props.transitionDuration)
   }

   calculateSliderPos() {
      const { currentPosition, carouselItems, itemWidth } = this.state

      let newPos = currentPosition < 0 ? carouselItems.length - 1 : 0

      this.setState(
         {
            currentPosition: newPos,
            translate3d: -itemWidth * (newPos === 0 ? 1 : carouselItems.length),
            disabled: false,
            cssAnimation: {
               transition: `transform 0ms ${this.props.cssEase}`,
            },
         },
         this.onSlideChange
      )
   }

   onSlideChange = () => {
      this.props.onCarouselChange(this.state.selectedItem)
      this.setState({ ...this.state, disabled: false })
   }

   setSliderStyles() {
      const currentPosition = `${-this.state.currentPosition * 100}%`
      let animationObject = {}

      if (this.props.animationType === 'slide') {
         animationObject = {
            transform: `translate3d(${currentPosition}, 0, 0)`,
            transition: `${this.props.transitionDuration}ms ${
               this.props.cssEase
            }`,
         }

         this.setState(
            {
               cssAnimation: animationObject,
            },
            () => this.props.onCarouselChange(this.state.currentPosition)
         )

         if (this.state.currentPosition === this.state.lastPosition) {
            // Reset the current slide position back to 0% with no transition
            clearTimeout(this.endTimer)
            this.endTimer = setTimeout(() => {
               this.setState({
                  cssAnimation: {
                     transform: 'translate3d(0px, 0, 0)',
                     transition: 'none',
                  },
               })
            }, this.props.transitionDuration)
         }
      } else if (this.props.animationType === 'fade') {
         this.changeTimer = setTimeout(() => {
            this.props.onCarouselChange(this.state.currentPosition)
         }, this.props.transitionDuration)
      } else {
         console.error(
            `You used the animation value ${
               this.props.animationType
            } which is not currently supported by the carousel. Please use one of 'slide' or 'fade'.`
         )
      }
   }

   itemStyles(index) {
      const { itemWidth, currentPosition } = this.state
      let style = {
         width: `${itemWidth}px`,
      }

      if (this.props.animationType === 'fade') {
         style = {
            left: `${-index * itemWidth}px`,
            opacity: 0,
            zIndex: -1,
            transition: `opacity ${this.props.transitionDuration}ms ${
               this.props.cssEase
            }`,
         }

         if (currentPosition === index) {
            style = Object.assign({}, style, {
               opacity: 1,
               zIndex: 1,
            })
         }
      }

      return style
   }

   changeItem = e => {
      const { value } = e.target
      this.moveTo(value)
   }

   renderCarouselItems = (item, index) => {
      const { carouselItems } = this.state

      const cloned = index < 1 || index > carouselItems.length + 1 - 1

      const className = [
         styles['carousel-item'],
         cloned && this.props.infinite === false
            ? styles['carousel-item--cloned']
            : '',
      ].join(' ')

      return (
         <li
            key={`item-${index}`}
            className={className}
            id={`item-${index}`}
            ref={el => (this[`item${index}`] = el)}
            style={this.itemStyles(index)}
            onClick={() => this.props.onCarouselItemClick(index)}
            onKeyPress={e => {
               console.log(e)
            }}
         >
            {item}

      )
   }

   getActiveDotIndex() {
      const { carouselItems, currentPosition } = this.state

      const currentIndex = currentPosition + 1
      const itemLength = carouselItems.length

      if (currentIndex < 1) {
         return itemLength - 1
      } else if (currentIndex > itemLength) {
         return 0
      } else {
         return currentIndex - 1
      }
   }

   onIndicatorMouseEnter = e => {
      const { value } = e.target
      this.setState({
         selectedIndicator: value,
      })
   }

   onIndicatorMouseLeave = e => {
      this.setState({ selectedIndicator: null })
   }

   renderIndicators() {
      const { carouselItems } = this.state

      if (!this.props.showIndicators) {
         return null
      }

      return (

            {carouselItems.map((item, index) => {
               const liClassName =
                  this.getActiveDotIndex() !== index
                     ? styles.dot
                     : [styles.dot, styles['dot-selected']].join(' ')

               const style =
                  this.state.selectedIndicator === index
                     ? {
                          opacity: 1,
                          transform: 'scale(1.2) translateZ(0)',
                          backfaceVisibility: 'hidden',
                          mixBlendMode: 'normal',
                       }
                     : {}

               return (

               )
            })}

      )
   }

   onArrowMouseEnter = (e, action) => {
      const key = `${action}ArrowHover`
      this.setState({
         [key]: true,
      })
   }

   onArrowMouseLeave = (e, action) => {
      const key = `${action}ArrowHover`
      this.setState({
         [key]: false,
      })
   }

   renderArrows = action => {
      const isPrev = action === 'previous'
      const isHover = this.state[`${action}ArrowHover`]

      const gradientDirection = isPrev ? 'to right' : 'to left'

      const style = {
         button: {
            background: `linear-gradient(
               ${gradientDirection},
               rgba(0, 0, 0, 0.2),
               rgba(0, 0, 0, 0)
            )`,
         },
         icon: {
            transition: 'transform 0.3s ease',
            opacity: isHover ? 1 : 0.4,
            transform: isHover
               ? 'scale(1.2) translateZ(0)'
               : 'scale(1) translateZ(0)',
            backfaceVisibility: 'hidden',
            mixBlendMode: 'normal',
         },
      }

      return (
         <button
            type="button"
            className={[styles['arrow'], styles[`arrow--${action}`]].join(' ')}
            onClick={isPrev ? this.decrement : this.increment}
            style={style.button}
            disabled={this.state.disabled}
            onMouseEnter={e => this.onArrowMouseEnter(e, action)}
            onMouseLeave={e => this.onArrowMouseLeave(e, action)}
         >
            <img
               height={'44px'}
               width={'44px'}
               style={style.icon}
               src={action === 'previous' ? leftChevron : rightChevron}
               alt={`${action} arrow`}
            />
         </button>
      )
   }

   render() {
      const { showArrows, showIndicators } = this.props
      const { carouselItems, cssAnimation, clones, translate3d } = this.state

      const items = clones || carouselItems

      const height = `${this.props.height}px`

      let sliderStyle
      if (this.props.animationType === 'fade') {
         sliderStyle = {
            transform: 'translate3d(0px, 0, 0)',
         }
      } else {
         sliderStyle = {
            ...cssAnimation,
            transform: `translate3d(${translate3d}px, 0, 0)`,
         }
      }

      return (

 (this.carouselWrapper = el)}
         >

 (this.sliderNode = node)}
                     onTouchStart={touchStartEvent =>
                        this.handleTouchStart(touchStartEvent)
                     }
                     onTouchMove={touchMoveEvent =>
                        this.handleTouchMove(touchMoveEvent)
                     }
                     onTouchEnd={() => this.handleTouchEnd()}
                  >
                     {/* Render Carousel Items */}
                     {items.map(this.renderCarouselItems)}

            {showArrows && this.renderArrows('previous')}
            {showArrows && this.renderArrows('next')}
            {showIndicators && this.renderIndicators()}

      )
   }
}
<commit_msg>Updates PropType for Carousel height to include both string and number<commit_after>import React, { Component } from 'react'
import PropTypes from 'prop-types'
import styles from './Carousel.css'

import { resize } from '../../utils/helpers'

import leftChevron from './chevron_left.svg'
import rightChevron from './chevron_right.svg'

export default class Carousel extends Component {
   static displayName = 'Carousel'

   static propTypes = {
      showIndicators: PropTypes.bool,
      infiniteLoop: PropTypes.bool,
      legendClassName: PropTypes.string,
      wrapperClassName: PropTypes.string,
      cssEase: PropTypes.string,
      animationType: PropTypes.oneOf(['fade', 'slide']),
      selectedItem: PropTypes.number,
      height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
      autoPlay: PropTypes.bool,
      stopOnHover: PropTypes.bool,
      intervalDuration: PropTypes.number,
      transitionDuration: PropTypes.number,
      children: PropTypes.node,
      redirectCallback: PropTypes.func,
      onCarouselChange: PropTypes.func,
      onCarouselItemClick: PropTypes.func,
      onCarouselMouseEnter: PropTypes.func,
      onCarouselMouseLeave: PropTypes.func,
      initializedCarousel: PropTypes.func,
      imagesLoaded: PropTypes.func,
   }

   static defaultProps = {
      showIndicators: true,
      infinite: true,
      showArrows: true,
      infiniteLoop: true,
      legendClassName: null,
      wrapperClassName: null,
      cssEase: 'ease',
      animationType: 'slide',
      selectedItem: 0,
      height: 0,
      autoPlay: false,
      stopOnHover: false,
      intervalDuration: 3500,
      transitionDuration: 800,
      redirectCallback: null,
      onCarouselChange: () => {},
      onCarouselMouseEnter: () => {},
      onCarouselMouseLeave: () => {},
      onCarouselItemClick: () => {},
      initializedCarousel: () => {},
      imageLoaded: () => {},
   }

   constructor(props) {
      super(props)
      this.autoPlayTimer = null
      this.changeTimer = null

      this.state = {
         isMouseEntered: false,
         currentPosition: props.selectedItem,
         lastPosition: null,
         carouselItems: [],
         cssAnimation: {},
         translate3d: null,
         initialized: false,
         fromChildren: false,
         disabled: false,
         previousArrowHover: false,
         nextArrowHover: false,
         selectedIndicator: null,
         ongoingTouches: [],
      }
   }

   componentDidMount() {
      const { children, autoPlay } = this.props

      if (children == null || children.length === 0) {
         return null
      }

      this.setupCarousel()
      resize.add(() => this.setupCarousel())

      if (autoPlay) {
         this.setupAutoPlay()
      }
   }

   componentDidUpdate() {
      if (!this.state.initialized) {
         this.setupCarousel()
      }

      if (this.props.autoPlay) {
         this.resetAutoPlay()
      }
   }

   componentWillUnmount() {
      clearTimeout(this.changeTimer)
   }

   setupCarousel() {
      const { children } = this.props
      const { currentPosition } = this.state

      const itemWidth = this.sliderNode.getBoundingClientRect().width
      const currentIndex = Math.min(
         currentPosition ? Math.abs(Math.ceil(currentPosition)) : 0,
         children.length - 1
      )

      this.setState(
         {
            currentPosition: currentIndex,
            initialized: true,
            carouselItems: children,
            clones: this.cloneItems(children),
            translate3d: -itemWidth * (1 + currentIndex),
            itemWidth,
         },
         () => this.props.initializedCarousel(true)
      )
   }

   cloneItems = children => {
      const firstChild = children.slice(0, 1)
      const lastChild = children.slice(children.length - 1)

      return [...lastChild, ...children, ...firstChild]
   }

   setupCarouselItems() {
      const items = this.props.children
      this.setState({
         lastPosition: items.length,
         carouselItems: items,
         fromChildren: true,
      })
   }

   setupAutoPlay() {
      this.autoPlay()
      const carouselWrapper = this.carouselWrapper

      if (this.props.stopOnHover && carouselWrapper) {
         // Stop mobile propagation
         // Required so touch/tap events don't cause mouseEnter/mouseLeave to fire
         const mobileTouch = (function isTouch() {
            try {
               document.createEvent('TouchEvent')
               return true
            } catch (e) {
               return false
            }
         })()

         if (!mobileTouch) {
            carouselWrapper.addEventListener(
               'mouseenter',
               ev => {
                  this.stopOnHover()
               },
               false
            )

            carouselWrapper.addEventListener('mouseleave', ev => {
               this.startOnHoverLeave()
            })
         }
      }
   }

   autoPlay() {
      this.autoPlayTimer = setTimeout(() => {
         this.increment()
      }, this.props.intervalDuration)
   }

   clearAutoPlay() {
      clearTimeout(this.autoPlayTimer)
      clearTimeout(this.calcCheckTimer)
      clearTimeout(this.changeTimer)
   }

   resetAutoPlay() {
      this.clearAutoPlay()
      this.autoPlay()
   }
   copyTouch(touch) {
      return {
         identifier: touch.identifier,
         pageX: touch.pageX,
         pageY: touch.pageY,
      }
   }
   handleTouchStart() {
      this.clearAutoPlay()
   }
   handleTouchMove(evt) {
      var touches = evt.changedTouches
      const lastTouch = this.state.ongoingTouches[
         this.state.ongoingTouches.length - 1
      ]
      this.setState({
         ...this.state,
         ongoingTouches: [
            ...this.state.ongoingTouches,
            this.copyTouch(touches[0]),
         ],
      })
   }
   handleTouchEnd() {
      this.resetAutoPlay()
      const touches = this.state.ongoingTouches.map(touch => {
         return touch.pageX
      })
      const change = touches[touches.length - 1] - touches[0]
      if (change > 0) {
         this.decrement()
      } else {
         this.increment()
      }
   }
   stopOnHover() {
      this.setState({
         isMouseEntered: true,
      })
      this.props.onCarouselMouseEnter(
         Date.now(),
         this.state.carouselItems[this.state.currentPosition].itemId
      )
      if (this.props.autoPlay) {
         this.clearAutoPlay()
      }
   }

   startOnHoverLeave() {
      this.setState({
         isMouseEntered: false,
      })
      this.props.onCarouselMouseLeave(
         Date.now(),
         this.state.carouselItems[this.state.currentPosition].itemId
      )
      if (this.props.autoPlay) {
         this.autoPlay()
      }
   }

   decrement = () => {
      this.moveTo(this.state.currentPosition - 1)
   }

   increment = () => {
      this.moveTo(this.state.currentPosition + 1)
   }

   moveTo = index => {
      const { itemWidth, isMouseEntered, carouselItems, disabled } = this.state

      const translate = (index + 1) * itemWidth

      this.setState(
         {
            disabled: true,
            currentPosition: index,
            translate3d: -translate,
            cssAnimation: {
               transition: `transform ${this.props.transitionDuration}ms ${
                  this.props.cssEase
               }`,
            },
         },
         this.recalculateChecker
      )

      // don't reset auto play when stop on hover is enabled, doing so will trigger a call to auto play more than once
      // and will result in the interval function not being cleared correctly.
      if (this.props.autoPlay && !isMouseEntered) {
         this.resetAutoPlay()
      }
   }

   recalculateChecker = () => {
      const { currentPosition, carouselItems } = this.state
      const recalc =
         currentPosition < 0 || currentPosition >= carouselItems.length

      window.clearTimeout()
      window.setTimeout(() => {
         recalc ? this.calculateSliderPos() : this.onSlideChange()
      }, this.props.transitionDuration)
   }

   calculateSliderPos() {
      const { currentPosition, carouselItems, itemWidth } = this.state

      let newPos = currentPosition < 0 ? carouselItems.length - 1 : 0

      this.setState(
         {
            currentPosition: newPos,
            translate3d: -itemWidth * (newPos === 0 ? 1 : carouselItems.length),
            disabled: false,
            cssAnimation: {
               transition: `transform 0ms ${this.props.cssEase}`,
            },
         },
         this.onSlideChange
      )
   }

   onSlideChange = () => {
      this.props.onCarouselChange(this.state.selectedItem)
      this.setState({ ...this.state, disabled: false })
   }

   setSliderStyles() {
      const currentPosition = `${-this.state.currentPosition * 100}%`
      let animationObject = {}

      if (this.props.animationType === 'slide') {
         animationObject = {
            transform: `translate3d(${currentPosition}, 0, 0)`,
            transition: `${this.props.transitionDuration}ms ${
               this.props.cssEase
            }`,
         }

         this.setState(
            {
               cssAnimation: animationObject,
            },
            () => this.props.onCarouselChange(this.state.currentPosition)
         )

         if (this.state.currentPosition === this.state.lastPosition) {
            // Reset the current slide position back to 0% with no transition
            clearTimeout(this.endTimer)
            this.endTimer = setTimeout(() => {
               this.setState({
                  cssAnimation: {
                     transform: 'translate3d(0px, 0, 0)',
                     transition: 'none',
                  },
               })
            }, this.props.transitionDuration)
         }
      } else if (this.props.animationType === 'fade') {
         this.changeTimer = setTimeout(() => {
            this.props.onCarouselChange(this.state.currentPosition)
         }, this.props.transitionDuration)
      } else {
         console.error(
            `You used the animation value ${
               this.props.animationType
            } which is not currently supported by the carousel. Please use one of 'slide' or 'fade'.`
         )
      }
   }

   itemStyles(index) {
      const { itemWidth, currentPosition } = this.state
      let style = {
         width: `${itemWidth}px`,
      }

      if (this.props.animationType === 'fade') {
         style = {
            left: `${-index * itemWidth}px`,
            opacity: 0,
            zIndex: -1,
            transition: `opacity ${this.props.transitionDuration}ms ${
               this.props.cssEase
            }`,
         }

         if (currentPosition === index) {
            style = Object.assign({}, style, {
               opacity: 1,
               zIndex: 1,
            })
         }
      }

      return style
   }

   changeItem = e => {
      const { value } = e.target
      this.moveTo(value)
   }

   renderCarouselItems = (item, index) => {
      const { carouselItems } = this.state

      const cloned = index < 1 || index > carouselItems.length + 1 - 1

      const className = [
         styles['carousel-item'],
         cloned && this.props.infinite === false
            ? styles['carousel-item--cloned']
            : '',
      ].join(' ')

      return (
         <li
            key={`item-${index}`}
            className={className}
            id={`item-${index}`}
            ref={el => (this[`item${index}`] = el)}
            style={this.itemStyles(index)}
            onClick={() => this.props.onCarouselItemClick(index)}
            onKeyPress={e => {
               console.log(e)
            }}
         >
            {item}

      )
   }

   getActiveDotIndex() {
      const { carouselItems, currentPosition } = this.state

      const currentIndex = currentPosition + 1
      const itemLength = carouselItems.length

      if (currentIndex < 1) {
         return itemLength - 1
      } else if (currentIndex > itemLength) {
         return 0
      } else {
         return currentIndex - 1
      }
   }

   onIndicatorMouseEnter = e => {
      const { value } = e.target
      this.setState({
         selectedIndicator: value,
      })
   }

   onIndicatorMouseLeave = e => {
      this.setState({ selectedIndicator: null })
   }

   renderIndicators() {
      const { carouselItems } = this.state

      if (!this.props.showIndicators) {
         return null
      }

      return (

            {carouselItems.map((item, index) => {
               const liClassName =
                  this.getActiveDotIndex() !== index
                     ? styles.dot
                     : [styles.dot, styles['dot-selected']].join(' ')

               const style =
                  this.state.selectedIndicator === index
                     ? {
                          opacity: 1,
                          transform: 'scale(1.2) translateZ(0)',
                          backfaceVisibility: 'hidden',
                          mixBlendMode: 'normal',
                       }
                     : {}

               return (

               )
            })}

      )
   }

   onArrowMouseEnter = (e, action) => {
      const key = `${action}ArrowHover`
      this.setState({
         [key]: true,
      })
   }

   onArrowMouseLeave = (e, action) => {
      const key = `${action}ArrowHover`
      this.setState({
         [key]: false,
      })
   }

   renderArrows = action => {
      const isPrev = action === 'previous'
      const isHover = this.state[`${action}ArrowHover`]

      const gradientDirection = isPrev ? 'to right' : 'to left'

      const style = {
         button: {
            background: `linear-gradient(
               ${gradientDirection},
               rgba(0, 0, 0, 0.2),
               rgba(0, 0, 0, 0)
            )`,
         },
         icon: {
            transition: 'transform 0.3s ease',
            opacity: isHover ? 1 : 0.4,
            transform: isHover
               ? 'scale(1.2) translateZ(0)'
               : 'scale(1) translateZ(0)',
            backfaceVisibility: 'hidden',
            mixBlendMode: 'normal',
         },
      }

      return (
         <button
            type="button"
            className={[styles['arrow'], styles[`arrow--${action}`]].join(' ')}
            onClick={isPrev ? this.decrement : this.increment}
            style={style.button}
            disabled={this.state.disabled}
            onMouseEnter={e => this.onArrowMouseEnter(e, action)}
            onMouseLeave={e => this.onArrowMouseLeave(e, action)}
         >
            <img
               height={'44px'}
               width={'44px'}
               style={style.icon}
               src={action === 'previous' ? leftChevron : rightChevron}
               alt={`${action} arrow`}
            />
         </button>
      )
   }

   render() {
      const { showArrows, showIndicators } = this.props
      const { carouselItems, cssAnimation, clones, translate3d } = this.state

      const items = clones || carouselItems

      const height = `${this.props.height}px`

      let sliderStyle
      if (this.props.animationType === 'fade') {
         sliderStyle = {
            transform: 'translate3d(0px, 0, 0)',
         }
      } else {
         sliderStyle = {
            ...cssAnimation,
            transform: `translate3d(${translate3d}px, 0, 0)`,
         }
      }

      return (

 (this.carouselWrapper = el)}
         >

 (this.sliderNode = node)}
                     onTouchStart={touchStartEvent =>
                        this.handleTouchStart(touchStartEvent)
                     }
                     onTouchMove={touchMoveEvent =>
                        this.handleTouchMove(touchMoveEvent)
                     }
                     onTouchEnd={() => this.handleTouchEnd()}
                  >
                     {/* Render Carousel Items */}
                     {items.map(this.renderCarouselItems)}

            {showArrows && this.renderArrows('previous')}
            {showArrows && this.renderArrows('next')}
            {showIndicators && this.renderIndicators()}

      )
   }
}
<|endoftext|>


<commit_before>'use strict';

var _ = require('lodash'),
    async = require('async'),
    config = require('api-umbrella-config').global(),
    dns = require('native-dns'),
    events = require('events'),
    ipaddr = require('ipaddr.js'),
    moment = require('moment'),
    redis = require('redis'),
    util = require('util');

var DnsResolver = function() {
  this.initialize.apply(this, arguments);
};

_.extend(DnsResolver, {
  NULL_ROUTE: '255.255.255.255',
  RECENT_IP_BUCKET_TIME_FORMAT: 'YYYY-MM-DDTHH',
});

util.inherits(DnsResolver, events.EventEmitter);
_.extend(DnsResolver.prototype, {
  resolved: {},
  resolveHostTimeouts: {},

  initialize: function(configReloader, callback) {
    this.configReloader = configReloader;

    async.parallel([
      this.connectRedis.bind(this),
      this.fetchSystemNameServers.bind(this),
    ], this.finishInit.bind(this, callback));
  },

  connectRedis: function(asyncReadyCallback) {
    this.redis = redis.createClient(config.get('redis.port'), config.get('redis.host'));

    this.redis.on('error', function(error) {
      asyncReadyCallback(error);
    });

    this.redis.on('ready', function() {
      asyncReadyCallback(null);
    });
  },

  fetchSystemNameServers: function(asyncReadyCallback) {
    this.nameServers = dns.platform.name_servers;
    if(this.nameServers && this.nameServers.length > 0) {
      asyncReadyCallback(null);
    } else {
      dns.platform.once('ready', function() {
        this.nameServers = dns.platform.name_servers;
        asyncReadyCallback(null);
      }.bind(this));
    }
  },

  finishInit: function(callback, error) {
    console.info('System DNS discovered: ', this.nameServers);

    // Watch for future changes to the system's DNS settings.
    dns.platform.watching = true;
    dns.platform.on('ready', this.handleNameServerChanges.bind(this));

    if(callback) {
      callback(error);
    }
  },

  handleNameServerChanges: function() {
    this.nameServers = dns.platform.name_servers;
    console.info('System DNS changes detected. Using new name servers: ', this.nameServers);
  },

  resolveAllHosts: function(callback) {
    var apis = config.get('internal_apis') || [];
    apis = apis.concat(config.get('apis') || []);
    console.info('Resolving all: ', apis);
    console.info('Resolving all: ', config.getAll());

    var hosts = [];
    apis.forEach(function(api) {
      if(api.servers) {
        hosts = hosts.concat(_.pluck(api.servers, 'host'));
      }
    });

    hosts = _.uniq(hosts);

    async.eachSeries(hosts, this.resolveHost.bind(this), this.handleResolveAllHosts.bind(this, callback));
  },

  handleResolveAllHosts: function(callback) {
    callback(null);
  },

  resolveHost: function(host, resolveHostCallback) {
    console.info('Resolving host: ', host);

    if(!this.resolved[host]) {
      this.resolved[host] = {
        ips: [],
        names: [],
      };
    }

    // If an IP address was given as the host, we don't need to do anything
    // else.
    if(ipaddr.isValid(host)) {
      _.extend(this.resolved[host], {
        ips: [host],
        existingIp: host,
        activeIp: host,
        ttl: null,
      });

      this.finishResolveHost(host, resolveHostCallback);
      return false;
    }

    async.series([
      this.fetchCachedHost.bind(this, host),
      this.sendHostQuery.bind(this, host),
      this.lookupFallback.bind(this, host),
      this.setActiveIp.bind(this, host),
    ], this.finishResolveHost.bind(this, host, resolveHostCallback));
  },

  fetchCachedHost: function(host, asyncCallback) {
    var resolved = this.resolved[host];
    if(resolved.activeIp) {
      resolved.existingIp = resolved.activeIp;
      resolved.activeIp = null;

      asyncCallback(null);
    } else {
      this.redis.get('router_active_ip:' + host, function(error, ip) {
        if(error) {
          console.error('Error fetching active IP: ', error);
          asyncCallback(error);
        }

        if(ip) {
          resolved.existingIp = ip;
        }

        asyncCallback(null);
      }.bind(this));
    }
  },

  sendHostQuery: function(host, asyncCallback) {
    var question = dns.Question({
      name: host,
      type: 'A',
    });

    var request = dns.Request({
      question: question,
      server: this.nameServers[0],
      timeout: 5000,
    });

    _.extend(this.resolved[host], {
      ips: [],
      names: [],
    });

    request.on('message', this.handleResolveHostMessage.bind(this, host));
    request.on('end', asyncCallback);

    request.send();
  },

  handleResolveHostMessage: function(host, error, message) {
    if(error) {
      return false;
    }

    var resolved = this.resolved[host];

    message.answer.forEach(function(answer) {
      if(answer.address) {
        console.info('Resolved host: ', host, answer.name, answer.address);
        resolved.ips.push(answer.address);
        resolved.ips = _.uniq(resolved.ips);

        if(answer.name) {
          resolved.names.push(answer.name);
          resolved.names = _.uniq(resolved.names);
        }

        if(answer.ttl && !resolved.ttl) {
          resolved.ttl = answer.ttl;
        }
      }
    });

    if(!resolved.ttl) {
      resolved.ttl = 60 * 60; // 1 hour
    }

    // Extremely large TTLs probably won't occur, but if they do, they break
    // setTimeout, so set a cap: http://stackoverflow.com/a/3468650/222487
    if(resolved.ttl > 2000000) {
      resolved.ttl = 2000000;
    }

    // Sporadically we've seen TTLs being reported back as 1 second for certain
    // domains for a while. This causes unnecessary cpu load and quick dns
    // polling, so force a minimum ttl of 40 seconds.
    if(resolved.ttl < config.get('dns_resolver.minimum_ttl')) {
      resolved.ttl = config.get('dns_resolver.minimum_ttl');
    }
  },

  lookupFallback: function(host, asyncCallback) {
    var resolved = this.resolved[host];

    // If the domain couldn't be resolved by the DNS A record query, try a
    // local lookup. This helps resolve local hostnames, like 'localhost' or
    // other aliases that might be used.
    if(!resolved || !resolved.ips || resolved.ips.length === 0) {
      dns.lookup(host, function(error, ip) {
        if(!error && ip) {
          _.extend(resolved, {
            ips: [ip],
            ttl: 60 * 60, // 1 hour
          });
        }

        asyncCallback(null);
      }.bind(this));
    } else {
      asyncCallback(null);
    }
  },

  setActiveIp: function(host, asyncCallback) {
    var resolved = this.resolved[host];

    this.addRecentlySeenIps(host, resolved, function() {
      this.getRecentlySeenIps(host, function(error, recentIps) {
        recentIps += resolved.ips;

        if(!resolved.activeIp) {
          if(recentIps && recentIps.length > 0) {
            // If we know what IP is already being used by the system, try to use that
            // one if it's still in the list of recently seen valid IPs.
            // Otherwise, pick the first one from the list.
            //
            // Because we're checking over the recently seen IPs in the past 4
            // hours, this doesn't exactly respect the true TTL of the domains.
            // But without this, we end up swapping IPs and reloading nginx too
            // frequently for domains behind CDNs like Akamai.
            if(resolved.existingIp && recentIps.indexOf(resolved.existingIp) !== -1) {
              resolved.activeIp = resolved.existingIp;
            } else {
              resolved.activeIp = resolved.ips[0];
            }
          } else {
            var ip;
            if(resolved.existingIp && resolved.existingIp !== DnsResolver.NULL_ROUTE) {
              ip = resolved.existingIp;
              console.error('Unable to resolve host: ' +  host + ' (using cached IP: ' + ip + ')');
            } else {
              ip = DnsResolver.NULL_ROUTE;
              console.error('Unable to resolve host: ' +  host + ' (using null route...)');
            }

            _.extend(resolved, {
              activeIp: ip,
              ttl: 2 * 60, // 2 minutes
            });
          }
        }

        asyncCallback(null);
      }.bind(this));
    }.bind(this));
  },

  finishResolveHost: function(host, resolveHostCallback) {
    var resolved = this.resolved[host];

    if(resolved.activeIp !== resolved.existingIp) {
      console.info('IP change for ' + host + ': ' + resolved.existingIp + ' => ' + resolved.activeIp);
      this.emit('hostChanged', host, resolved.activeIp);
    }

    if(resolveHostCallback) {
      resolveHostCallback(null);
    }

    if(resolved.ttl) {
      console.info('Scheduling refresh of ' + host + ' (' + resolved.activeIp + ') in ' + resolved.ttl + ' seconds');
      this.resolveHostTimeouts[host] = setTimeout(this.resolveHost.bind(this, host), resolved.ttl * 1000);
    }

    if(resolved.activeIp !== resolved.existingIp) {
      this.redis.set('router_active_ip:' + host, resolved.activeIp, function(error) {
        if(error) {
          console.error('Error caching active IP: ', error);
        }
      });
    }
  },

  addRecentlySeenIps: function(host, resolved, callback) {
    var ips = resolved.ips;
    if(!ips || ips.length === 0) {
      callback(null);
      return false;
    }

    var names = [host];
    if(resolved.names) {
      names = names.concat(resolved.names);
    }

    var ignoreTtlDomains = config.get('dns_resolver.ignore_ttl_domains');
    var ignoreTtl = false;
    if(ignoreTtlDomains) {
      ignoreTtlDomains.forEach(function(ignore) {
        names.forEach(function(name) {
          if(name.match(ignore)) {
            ignoreTtl = true;
          }
        });
      });
    }

    if(!ignoreTtl) {
      callback(null);
      return false;
    }

    var setKey = 'router_seen_ips:' + host + ':' + moment().format(DnsResolver.RECENT_IP_BUCKET_TIME_FORMAT);

    console.info('Set: ', host, ignoreTtl, ips);
    this.redis.multi()
      .sadd(setKey, ips)
      .expire(setKey, 5 * 60 * 60) // 5 hours
      .exec(function(error) {
        if(error) {
          console.error('Error setting recently seen ips:', error);
        }

        callback(null);
      });
  },

  getRecentlySeenIps: function(host, callback) {
    var now = moment();
    async.times(4, function(i, setCallback) {
      var hour = now.subtract('hours', i);
      var setKey = 'router_seen_ips:' + host + ':' + hour.format(DnsResolver.RECENT_IP_BUCKET_TIME_FORMAT);
      this.redis.smembers(setKey, function(error, ips) {
        if(error) {
          console.error('Error fetching recently seen ips:', error);
          setCallback(null, []);
          return false;
        }

        setCallback(null, ips || []);
      }.bind(this));
    }.bind(this), function(error, ips) {
      var allIps = _.uniq(_.flatten(ips));
      if(allIps && allIps.length > 0) {
        console.info('Recently cached IPs: ', host, ips);
      }

      callback(error, allIps);
    });
  },

  getIp: function(host) {
    var ip;
    if(this.resolved[host]) {
      ip = this.resolved[host].activeIp;
    }

    if(!ip) {
      console.error('Unexpectedly fetching IP for unknown host: ', host);
      ip = DnsResolver.NULL_ROUTE;
    }

    return ip;
  },

  close: function(callback) {
    if(this.redis) {
      this.redis.quit();
    }

    if(this.resolveHostTimeouts) {
      for(var host in this.resolveHostTimeouts) {
        clearTimeout(this.resolveHostTimeouts[host]);
      }
    }

    if(callback) {
      callback(null);
    }
  },
});

module.exports.DnsResolver = DnsResolver;
<commit_msg>Pull in edge-case DNS resolver issue from gatekeeper codebase.<commit_after>'use strict';

var _ = require('lodash'),
    async = require('async'),
    config = require('api-umbrella-config').global(),
    dns = require('native-dns'),
    events = require('events'),
    ipaddr = require('ipaddr.js'),
    moment = require('moment'),
    redis = require('redis'),
    util = require('util');

var DnsResolver = function() {
  this.initialize.apply(this, arguments);
};

_.extend(DnsResolver, {
  NULL_ROUTE: '255.255.255.255',
  RECENT_IP_BUCKET_TIME_FORMAT: 'YYYY-MM-DDTHH',
});

util.inherits(DnsResolver, events.EventEmitter);
_.extend(DnsResolver.prototype, {
  resolved: {},
  resolveHostTimeouts: {},

  initialize: function(configReloader, callback) {
    this.configReloader = configReloader;

    async.parallel([
      this.connectRedis.bind(this),
      this.fetchSystemNameServers.bind(this),
    ], this.finishInit.bind(this, callback));
  },

  connectRedis: function(asyncReadyCallback) {
    this.redis = redis.createClient(config.get('redis.port'), config.get('redis.host'));

    this.redis.on('error', function(error) {
      asyncReadyCallback(error);
    });

    this.redis.on('ready', function() {
      asyncReadyCallback(null);
    });
  },

  fetchSystemNameServers: function(asyncReadyCallback) {
    this.nameServers = dns.platform.name_servers;
    if(this.nameServers && this.nameServers.length > 0) {
      asyncReadyCallback(null);
    } else {
      dns.platform.once('ready', function() {
        this.nameServers = dns.platform.name_servers;
        asyncReadyCallback(null);
      }.bind(this));
    }
  },

  finishInit: function(callback, error) {
    console.info('System DNS discovered: ', this.nameServers);

    // Watch for future changes to the system's DNS settings.
    dns.platform.watching = true;
    dns.platform.on('ready', this.handleNameServerChanges.bind(this));

    if(callback) {
      callback(error);
    }
  },

  handleNameServerChanges: function() {
    this.nameServers = dns.platform.name_servers;
    console.info('System DNS changes detected. Using new name servers: ', this.nameServers);
  },

  resolveAllHosts: function(callback) {
    var apis = config.get('internal_apis') || [];
    apis = apis.concat(config.get('apis') || []);

    var hosts = [];
    apis.forEach(function(api) {
      if(api.servers) {
        hosts = hosts.concat(_.pluck(api.servers, 'host'));
      }
    });

    hosts = _.uniq(hosts);

    async.eachSeries(hosts, this.resolveHost.bind(this), this.handleResolveAllHosts.bind(this, callback));
  },

  handleResolveAllHosts: function(callback) {
    callback(null);
  },

  resolveHost: function(host, resolveHostCallback) {
    console.info('Resolving host: ', host);

    if(!this.resolved[host]) {
      this.resolved[host] = {
        ips: [],
        names: [],
      };
    }

    // If an IP address was given as the host, we don't need to do anything
    // else.
    if(ipaddr.isValid(host)) {
      _.extend(this.resolved[host], {
        ips: [host],
        existingIp: host,
        activeIp: host,
        ttl: null,
      });

      this.finishResolveHost(host, resolveHostCallback);
      return false;
    }

    async.series([
      this.fetchCachedHost.bind(this, host),
      this.sendHostQuery.bind(this, host),
      this.lookupFallback.bind(this, host),
      this.setActiveIp.bind(this, host),
    ], this.finishResolveHost.bind(this, host, resolveHostCallback));
  },

  fetchCachedHost: function(host, asyncCallback) {
    var resolved = this.resolved[host];
    if(resolved.activeIp) {
      resolved.existingIp = resolved.activeIp;
      resolved.activeIp = null;

      asyncCallback(null);
    } else {
      this.redis.get('router_active_ip:' + host, function(error, ip) {
        if(error) {
          console.error('Error fetching active IP: ', error);
          asyncCallback(error);
        }

        if(ip) {
          resolved.existingIp = ip;
        }

        asyncCallback(null);
      }.bind(this));
    }
  },

  sendHostQuery: function(host, asyncCallback) {
    var question = dns.Question({
      name: host,
      type: 'A',
    });

    var request = dns.Request({
      question: question,
      server: this.nameServers[0],
      timeout: 5000,
    });

    _.extend(this.resolved[host], {
      ips: [],
      names: [],
    });

    request.on('message', this.handleResolveHostMessage.bind(this, host));
    request.on('end', asyncCallback);

    request.send();
  },

  handleResolveHostMessage: function(host, error, message) {
    if(error) {
      return false;
    }

    var resolved = this.resolved[host];

    message.answer.forEach(function(answer) {
      if(answer.address) {
        console.info('Resolved host: ', host, answer.name, answer.address);
        resolved.ips.push(answer.address);
        resolved.ips = _.uniq(resolved.ips);

        if(answer.name) {
          resolved.names.push(answer.name);
          resolved.names = _.uniq(resolved.names);
        }

        if(answer.ttl && !resolved.ttl) {
          resolved.ttl = answer.ttl;
        }
      }
    });

    if(!resolved.ttl) {
      resolved.ttl = 60 * 60; // 1 hour
    }

    // Extremely large TTLs probably won't occur, but if they do, they break
    // setTimeout, so set a cap: http://stackoverflow.com/a/3468650/222487
    if(resolved.ttl > 2000000) {
      resolved.ttl = 2000000;
    }

    // Sporadically we've seen TTLs being reported back as 1 second for certain
    // domains for a while. This causes unnecessary cpu load and quick dns
    // polling, so force a minimum ttl of 40 seconds.
    if(resolved.ttl < config.get('dns_resolver.minimum_ttl')) {
      resolved.ttl = config.get('dns_resolver.minimum_ttl');
    }
  },

  lookupFallback: function(host, asyncCallback) {
    var resolved = this.resolved[host];

    // If the domain couldn't be resolved by the DNS A record query, try a
    // local lookup. This helps resolve local hostnames, like 'localhost' or
    // other aliases that might be used.
    if(!resolved || !resolved.ips || resolved.ips.length === 0) {
      dns.lookup(host, function(error, ip) {
        if(!error && ip) {
          _.extend(resolved, {
            ips: [ip],
            ttl: 60 * 60, // 1 hour
          });
        } else {
          console.error('Unable to resolve host via lookup or fallback: ' +  host);
        }

        asyncCallback(null);
      }.bind(this));
    } else {
      asyncCallback(null);
    }
  },

  setActiveIp: function(host, asyncCallback) {
    var resolved = this.resolved[host];

    this.addRecentlySeenIps(host, resolved, function() {
      this.getRecentlySeenIps(host, function(error, recentIps) {
        recentIps += resolved.ips;

        if(!resolved.activeIp && recentIps && recentIps.length > 0) {
          // If we know what IP is already being used by the system, try to use that
          // one if it's still in the list of recently seen valid IPs.
          // Otherwise, pick the first one from the list.
          //
          // Because we're checking over the recently seen IPs in the past 2
          // hours, this doesn't exactly respect the true TTL of the domains.
          // But without this, we end up swapping IPs and reloading nginx too
          // frequently for domains behind CDNs like Akamai.
          if(resolved.existingIp && recentIps.indexOf(resolved.existingIp) !== -1) {
            resolved.activeIp = resolved.existingIp;
          } else if(resolved.ips && resolved.ips[0]) {
            resolved.activeIp = resolved.ips[0];
          }
        }

        if(!resolved.activeIp) {
          var ip;
          if(resolved.existingIp && resolved.existingIp !== DnsResolver.NULL_ROUTE) {
            ip = resolved.existingIp;
            console.error('Unable to resolve host: ' +  host + ' (using cached IP: ' + ip + ')');
          } else if(recentIps && recentIps[0] && recentIps[0] !== DnsResolver.NULL_ROUTE) {
            ip = recentIps[0];
            console.error('Unable to resolve host: ' +  host + ' (using recent IP: ' + ip + ')');
          } else {
            ip = DnsResolver.NULL_ROUTE;
            console.error('Unable to resolve host: ' +  host + ' (using null route...)');
          }

          _.extend(resolved, {
            activeIp: ip,
            ttl: 2 * 60, // 2 minutes
          });
        }

        asyncCallback(null);
      }.bind(this));
    }.bind(this));
  },

  finishResolveHost: function(host, resolveHostCallback) {
    var resolved = this.resolved[host];

    if(resolved.activeIp !== resolved.existingIp) {
      console.info('IP change for ' + host + ': ' + resolved.existingIp + ' => ' + resolved.activeIp);
      this.emit('hostChanged', host, resolved.activeIp);
    }

    if(resolveHostCallback) {
      resolveHostCallback(null);
    }

    if(resolved.ttl) {
      console.info('Scheduling refresh of ' + host + ' (' + resolved.activeIp + ') in ' + resolved.ttl + ' seconds');
      this.resolveHostTimeouts[host] = setTimeout(this.resolveHost.bind(this, host), resolved.ttl * 1000);
    }

    if(resolved.activeIp !== resolved.existingIp) {
      this.redis.set('router_active_ip:' + host, resolved.activeIp, function(error) {
        if(error) {
          console.error('Error caching active IP: ', error);
        }
      });
    }
  },

  addRecentlySeenIps: function(host, resolved, callback) {
    var ips = resolved.ips;
    if(!ips || ips.length === 0) {
      callback(null);
      return false;
    }

    var names = [host];
    if(resolved.names) {
      names = names.concat(resolved.names);
    }

    var ignoreTtlDomains = config.get('dns_resolver.ignore_ttl_domains');
    var ignoreTtl = false;
    if(ignoreTtlDomains) {
      ignoreTtlDomains.forEach(function(ignore) {
        names.forEach(function(name) {
          if(name.match(ignore)) {
            ignoreTtl = true;
          }
        });
      });
    }

    if(!ignoreTtl) {
      callback(null);
      return false;
    }

    var setKey = 'router_seen_ips:' + host + ':' + moment().format(DnsResolver.RECENT_IP_BUCKET_TIME_FORMAT);

    console.info('Set: ', host, ignoreTtl, ips);
    this.redis.multi()
      .sadd(setKey, ips)
      .expire(setKey, 5 * 60 * 60) // 5 hours
      .exec(function(error) {
        if(error) {
          console.error('Error setting recently seen ips:', error);
        }

        callback(null);
      });
  },

  getRecentlySeenIps: function(host, callback) {
    var now = moment();
    async.times(2, function(i, setCallback) {
      var hour = now.subtract('hours', i);
      var setKey = 'router_seen_ips:' + host + ':' + hour.format(DnsResolver.RECENT_IP_BUCKET_TIME_FORMAT);
      this.redis.smembers(setKey, function(error, ips) {
        if(error) {
          console.error('Error fetching recently seen ips:', error);
          setCallback(null, []);
          return false;
        }

        setCallback(null, ips || []);
      }.bind(this));
    }.bind(this), function(error, ips) {
      var allIps = _.uniq(_.flatten(ips));
      if(allIps && allIps.length > 0) {
        console.info('Recently cached IPs: ', host, ips);
      }

      callback(error, allIps);
    });
  },

  getIp: function(host) {
    var ip;
    if(this.resolved[host]) {
      ip = this.resolved[host].activeIp;
    }

    if(!ip) {
      console.error('Unexpectedly fetching IP for unknown host: ', host);
      ip = DnsResolver.NULL_ROUTE;
    }

    return ip;
  },

  close: function(callback) {
    if(this.redis) {
      this.redis.quit();
    }

    if(this.resolveHostTimeouts) {
      for(var host in this.resolveHostTimeouts) {
        clearTimeout(this.resolveHostTimeouts[host]);
      }
    }

    if(callback) {
      callback(null);
    }
  },
});

module.exports.DnsResolver = DnsResolver;
<|endoftext|>