diff --git a/js/AbstractSpectrumNode.js b/js/AbstractSpectrumNode.js new file mode 100644 index 000000000..9bf1f82c2 --- /dev/null +++ b/js/AbstractSpectrumNode.js @@ -0,0 +1,60 @@ +// Copyright 2014-2017, University of Colorado Boulder + +/** + * AbstractSpectrumNode displays a rectangle of the visible spectrum. + * + * @author Chris Malley (PixelZoom, Inc.) + */ +define( function( require ) { + 'use strict'; + + // modules + var Bounds2 = require( 'DOT/Bounds2' ); + var Dimension2 = require( 'DOT/Dimension2' ); + var Image = require( 'SCENERY/nodes/Image' ); + var inherit = require( 'PHET_CORE/inherit' ); + var sceneryPhet = require( 'SCENERY_PHET/sceneryPhet' ); + var Util = require( 'DOT/Util' ); + + /** + * Slider track that displays the visible spectrum. + * + * @param {Object} [options] + * @constructor + */ + function AbstractSpectrumNode( options ) { + + options = _.extend( { + size: new Dimension2( 150, 30 ), + minValue: 0, + maxValue: 1, + valueToColor: null // {function} - required, maps value => Color + }, options ); + + // validate values + assert && assert( options.minValue < options.maxValue, 'min should be less than max' ); + assert && assert( !!options.valueToColor, 'valueToColor is required' ); + + // Draw the spectrum directly to a canvas, to improve performance. + var canvas = document.createElement( 'canvas' ); + var context = canvas.getContext( '2d' ); + canvas.width = options.size.width; + canvas.height = options.size.height; + + // map position to wavelength + for ( var i = 0; i < options.size.width; i++ ) { + var value = Util.clamp( Util.linear( 0, options.size.width, options.minValue, options.maxValue, i ), options.minValue, options.maxValue ); + context.fillStyle = options.valueToColor( value ).toCSS(); + context.fillRect( i, 0, 1, options.size.height ); + } + + Image.call( this, canvas.toDataURL(), options ); + + // since the Image's bounds aren't immediately computed, we override it here + this.setLocalBounds( new Bounds2( 0, 0, options.size.width, options.size.height ) ); + } + + sceneryPhet.register( 'AbstractSpectrumNode', AbstractSpectrumNode ); + + return inherit( Image, AbstractSpectrumNode ); +} ); diff --git a/js/FrequencySlider.js b/js/FrequencySlider.js new file mode 100644 index 000000000..79a9e75cb --- /dev/null +++ b/js/FrequencySlider.js @@ -0,0 +1,54 @@ +// Copyright 2018, University of Colorado Boulder + +/** + * Slider that shows a spectrum of colors for selecting a frequency. + * + * @author Sam Reid (PhET Interactive Simulations) + */ +define( function( require ) { + 'use strict'; + + // modules + var inherit = require( 'PHET_CORE/inherit' ); + var sceneryPhet = require( 'SCENERY_PHET/sceneryPhet' ); + var SpectrumSlider = require( 'SCENERY_PHET/SpectrumSlider' ); + var StringUtils = require( 'PHETCOMMON/util/StringUtils' ); + var Util = require( 'DOT/Util' ); + var VisibleColor = require( 'SCENERY_PHET/VisibleColor' ); + + // strings + var frequencySliderPattern0Frequency1UnitsString = require( 'string!SCENERY_PHET/FrequencySlider.pattern_0frequency_1units' ); + var unitsTHzString = require( 'string!SCENERY_PHET/unitsTHz' ); + + /** + * @param {Property.} wavelengthProperty - wavelength, in nm + * @param {Object} [options] + * @constructor + */ + function FrequencySlider( wavelengthProperty, options ) { + + // options that are specific to this type + options = _.extend( { + + minFrequency: VisibleColor.MIN_FREQUENCY, + maxFrequency: VisibleColor.MAX_FREQUENCY, + valueToString: function( value ) { + return StringUtils.format( frequencySliderPattern0Frequency1UnitsString, Util.toFixed( value / 1E12, 0 ), unitsTHzString ); + }, + valueToColor: function( value ) { + return VisibleColor.frequencyToColor( value ); + } + }, options ); + assert && assert( typeof options.minValue === 'undefined', 'minValue is supplied by FrequencySlider' ); + assert && assert( typeof options.maxValue === 'undefined', 'maxValue is supplied by FrequencySlider' ); + assert && assert( typeof options.createTrackNode === 'undefined', 'createTrackNode is supplied by FrequencySlider' ); + options.minValue = options.minFrequency; + options.maxValue = options.maxFrequency; + + SpectrumSlider.call( this, wavelengthProperty, options ); + } + + sceneryPhet.register( 'FrequencySlider', FrequencySlider ); + + return inherit( SpectrumSlider, FrequencySlider ); +} ); \ No newline at end of file diff --git a/js/SpectrumNode.js b/js/SpectrumNode.js index 1924f48bd..4b0917545 100644 --- a/js/SpectrumNode.js +++ b/js/SpectrumNode.js @@ -3,22 +3,21 @@ /** * SpectrumNode displays a rectangle of the visible spectrum. * + * TODO: Rename this to WavelengthSpectrumNode, and rename AbstractSpectrumNode to SpectrumNode + * * @author Chris Malley (PixelZoom, Inc.) */ define( function( require ) { 'use strict'; // modules - var Bounds2 = require( 'DOT/Bounds2' ); - var Dimension2 = require( 'DOT/Dimension2' ); - var Image = require( 'SCENERY/nodes/Image' ); var inherit = require( 'PHET_CORE/inherit' ); var sceneryPhet = require( 'SCENERY_PHET/sceneryPhet' ); - var Util = require( 'DOT/Util' ); var VisibleColor = require( 'SCENERY_PHET/VisibleColor' ); + var AbstractSpectrumNode = require( 'SCENERY_PHET/AbstractSpectrumNode' ); /** - * Slider track that displays the visible spectrum. + * Slider track that displays the visible spectrum of light. * * @param {Object} [options] * @constructor @@ -26,35 +25,27 @@ define( function( require ) { function SpectrumNode( options ) { options = _.extend( { - size: new Dimension2( 150, 30 ), minWavelength: VisibleColor.MIN_WAVELENGTH, maxWavelength: VisibleColor.MAX_WAVELENGTH }, options ); - // validate wavelengths + // validation assert && assert( options.minWavelength < options.maxWavelength ); assert && assert( options.minWavelength >= VisibleColor.MIN_WAVELENGTH && options.minWavelength <= VisibleColor.MAX_WAVELENGTH ); assert && assert( options.maxWavelength >= VisibleColor.MIN_WAVELENGTH && options.maxWavelength <= VisibleColor.MAX_WAVELENGTH ); + assert && assert( typeof options.minValue === 'undefined', 'minValue is supplied by WavelengthSlider' ); + assert && assert( typeof options.maxValue === 'undefined', 'maxValue is supplied by WavelengthSlider' ); + + options.minValue = options.minWavelength; + options.maxValue = options.maxWavelength; + options.valueToColor = function( value ) { + return VisibleColor.wavelengthToColor( value ); + }; - // Draw the spectrum directly to a canvas, to improve performance. - var canvas = document.createElement( 'canvas' ); - var context = canvas.getContext( '2d' ); - canvas.width = options.size.width; - canvas.height = options.size.height; - for ( var i = 0; i < options.size.width; i++ ) { - // map position to wavelength - var wavelength = Util.clamp( Util.linear( 0, options.size.width, options.minWavelength, options.maxWavelength, i ), options.minWavelength, options.maxWavelength ); - context.fillStyle = VisibleColor.wavelengthToColor( wavelength ).toCSS(); - context.fillRect( i, 0, 1, options.size.height ); - } - - Image.call( this, canvas.toDataURL(), options ); - - // since the Image's bounds aren't immediately computed, we override it here - this.setLocalBounds( new Bounds2( 0, 0, options.size.width, options.size.height ) ); + AbstractSpectrumNode.call( this, options ); } sceneryPhet.register( 'SpectrumNode', SpectrumNode ); - return inherit( Image, SpectrumNode ); -} ); + return inherit( AbstractSpectrumNode, SpectrumNode ); +} ); \ No newline at end of file diff --git a/js/SpectrumSlider.js b/js/SpectrumSlider.js new file mode 100644 index 000000000..e2f4b0ffe --- /dev/null +++ b/js/SpectrumSlider.js @@ -0,0 +1,433 @@ +// Copyright 2013-2018, University of Colorado Boulder + +/** + * SpectrumSlider is a slider-like control used for choosing a value that corresponds to a displayed color. + * It is a parent type for WavelengthSlider and FrequencySlider. + * + * @author Chris Malley (PixelZoom, Inc.) + * @author Sam Reid (PhET Interactive Simulations) + */ +define( function( require ) { + 'use strict'; + + // modules + var AbstractSpectrumNode = require( 'SCENERY_PHET/AbstractSpectrumNode' ); + var AccessibleSlider = require( 'SUN/accessibility/AccessibleSlider' ); + var ArrowButton = require( 'SUN/buttons/ArrowButton' ); + var BooleanProperty = require( 'AXON/BooleanProperty' ); + var Color = require( 'SCENERY/util/Color' ); + var Dimension2 = require( 'DOT/Dimension2' ); + var FocusHighlightFromNode = require( 'SCENERY/accessibility/FocusHighlightFromNode' ); + var inherit = require( 'PHET_CORE/inherit' ); + var Node = require( 'SCENERY/nodes/Node' ); + var Path = require( 'SCENERY/nodes/Path' ); + var PhetFont = require( 'SCENERY_PHET/PhetFont' ); + var Property = require( 'AXON/Property' ); + var Range = require( 'DOT/Range' ); + var Rectangle = require( 'SCENERY/nodes/Rectangle' ); + var sceneryPhet = require( 'SCENERY_PHET/sceneryPhet' ); + var Shape = require( 'KITE/Shape' ); + var SimpleDragHandler = require( 'SCENERY/input/SimpleDragHandler' ); + var Tandem = require( 'TANDEM/Tandem' ); + var Text = require( 'SCENERY/nodes/Text' ); + var Util = require( 'DOT/Util' ); + + /** + * @param {Property.} valueProperty - wavelength, in nm + * @param {Object} [options] + * @constructor + */ + function SpectrumSlider( valueProperty, options ) { + + var self = this; + + // options that are specific to this type + options = _.extend( { + + // {number} The minimum value to be displayed + minValue: 0, + + // {number} The minimum value to be displayed + maxValue: 1, + + // {function} Maps {number} to text that is optionally displayed by the slider + valueToString: function( value ) {return value + '';}, + + // {function} Maps {number} to Color that is rendered in the spectrum and in the thumb + valueToColor: function( value ) {return new Color( 0, 0, 255 * value );}, + + // track + trackWidth: 150, + trackHeight: 30, + trackOpacity: 1, + trackBorderStroke: 'black', + + // thumb + thumbWidth: 35, + thumbHeight: 45, + thumbTouchAreaXDilation: 12, + thumbTouchAreaYDilation: 10, + thumbMouseAreaXDilation: 0, + thumbMouseAreaYDilation: 0, + + // value + valueFont: new PhetFont( 20 ), + valueFill: 'black', + valueVisible: true, + valueYSpacing: 2, // {number} space between value and top of track + + // tweakers + tweakersVisible: true, + tweakersXSpacing: 8, // {number} space between tweakers and track + maxTweakersHeight: 30, + tweakersTouchAreaXDilation: 7, + tweakersTouchAreaYDilation: 7, + tweakersMouseAreaXDilation: 0, + tweakersMouseAreaYDilation: 0, + + // cursor, the rectangle than follows the thumb in the track + cursorVisible: true, + cursorStroke: 'black', + + // phet-io + tandem: Tandem.required + + }, options ); + + // validate wavelengths + assert && assert( options.minValue < options.maxValue ); + + Node.call( this ); + + var track = new AbstractSpectrumNode( { + valueToColor: options.valueToColor, + size: new Dimension2( options.trackWidth, options.trackHeight ), + minValue: options.minValue, + maxValue: options.maxValue, + opacity: options.trackOpacity, + cursor: 'pointer' + } ); + + /* + * Put a border around the track. + * We don't stroke the track itself because stroking the track will affect its bounds, + * and will thus affect the drag handle behavior. + * Having a separate border also gives subclasses a place to add markings (eg, tick marks) + * without affecting the track's bounds. + */ + var trackBorder = new Rectangle( 0, 0, track.width, track.height, { + stroke: options.trackBorderStroke, + lineWidth: 1, + pickable: false + } ); + + var valueDisplay; + if ( options.valueVisible ) { + valueDisplay = new ValueDisplay( valueProperty, options.valueToString, { + font: options.valueFont, + fill: options.valueFill, + bottom: track.top - options.valueYSpacing + } ); + } + + var cursor; + if ( options.cursorVisible ) { + cursor = new Cursor( 3, track.height, { + stroke: options.cursorStroke, + top: track.top + } ); + } + + var thumb = new Thumb( options.thumbWidth, options.thumbHeight, { + cursor: 'pointer', + top: track.bottom + } ); + + // thumb touchArea + if ( options.thumbTouchAreaXDilation || options.thumbTouchAreaYDilation ) { + thumb.touchArea = thumb.localBounds + .dilatedXY( options.thumbTouchAreaXDilation, options.thumbTouchAreaYDilation ) + .shiftedY( options.thumbTouchAreaYDilation ); + } + + // thumb mouseArea + if ( options.thumbMouseAreaXDilation || options.thumbMouseAreaYDilation ) { + thumb.mouseArea = thumb.localBounds + .dilatedXY( options.thumbMouseAreaXDilation, options.thumbMouseAreaYDilation ) + .shiftedY( options.thumbMouseAreaYDilation ); + } + + // tweaker buttons for single-unit increments + var plusButton; + var minusButton; + if ( options.tweakersVisible ) { + + plusButton = new ArrowButton( 'right', function() { + valueProperty.set( valueProperty.get() + 1 ); + }, { + left: track.right + options.tweakersXSpacing, + centerY: track.centerY, + maxHeight: options.maxTweakersHeight, + tandem: options.tandem.createTandem( 'plusButton' ) + } ); + + minusButton = new ArrowButton( 'left', function() { + valueProperty.set( valueProperty.get() - 1 ); + }, { + right: track.left - options.tweakersXSpacing, + centerY: track.centerY, + maxHeight: options.maxTweakersHeight, + tandem: options.tandem.createTandem( 'minusButton' ) + } ); + + // tweakers touchArea + plusButton.touchArea = plusButton.localBounds + .dilatedXY( options.tweakersTouchAreaXDilation, options.tweakersTouchAreaYDilation ) + .shiftedX( options.tweakersTouchAreaXDilation ); + minusButton.touchArea = minusButton.localBounds + .dilatedXY( options.tweakersTouchAreaXDilation, options.tweakersTouchAreaYDilation ) + .shiftedX( -options.tweakersTouchAreaXDilation ); + + // tweakers mouseArea + plusButton.mouseArea = plusButton.localBounds + .dilatedXY( options.tweakersMouseAreaXDilation, options.tweakersMouseAreaYDilation ) + .shiftedX( options.tweakersMouseAreaXDilation ); + minusButton.mouseArea = minusButton.localBounds + .dilatedXY( options.tweakersMouseAreaXDilation, options.tweakersMouseAreaYDilation ) + .shiftedX( -options.tweakersMouseAreaXDilation ); + } + + // rendering order + this.addChild( track ); + this.addChild( trackBorder ); + this.addChild( thumb ); + valueDisplay && this.addChild( valueDisplay ); + cursor && this.addChild( cursor ); + plusButton && this.addChild( plusButton ); + minusButton && this.addChild( minusButton ); + + // transforms between position and value + var positionToValue = function( x ) { + return Util.clamp( Util.linear( 0, track.width, options.minValue, options.maxValue, x ), options.minValue, options.maxValue ); + }; + var valueToPosition = function( wavelength ) { + return Util.clamp( Util.linear( options.minValue, options.maxValue, 0, track.width, wavelength ), 0, track.width ); + }; + + // click in the track to change the value, continue dragging if desired + var handleTrackEvent = function( event ) { + var x = thumb.globalToParentPoint( event.pointer.point ).x; + var wavelength = positionToValue( x ); + valueProperty.set( wavelength ); + }; + + track.addInputListener( new SimpleDragHandler( { + + tandem: options.tandem.createTandem( 'trackInputListener' ), + + start: function( event, trail ) { + handleTrackEvent( event ); + }, + + drag: function( event, trail ) { + handleTrackEvent( event ); + } + } ) ); + + // thumb drag handler + var clickXOffset = 0; // x-offset between initial click and thumb's origin + thumb.addInputListener( new SimpleDragHandler( { + + tandem: options.tandem.createTandem( 'thumbInputListener' ), + + allowTouchSnag: true, + + start: function( event ) { + clickXOffset = thumb.globalToParentPoint( event.pointer.point ).x - thumb.x; + }, + + drag: function( event ) { + var x = thumb.globalToParentPoint( event.pointer.point ).x - clickXOffset; + var value = positionToValue( x ); + valueProperty.set( value ); + } + } ) ); + + // @public (a11y) - custom focus highlight that surrounds and moves with the thumb + this.focusHighlight = new FocusHighlightFromNode( thumb ); + + // sync with model + var updateUI = function( value ) { + // positions + var x = valueToPosition( value ); + thumb.centerX = x; + self.focusHighlight.centerX = thumb.centerX; + if ( cursor ) { cursor.centerX = x; } + if ( valueDisplay ) { valueDisplay.centerX = x; } + // thumb color + thumb.fill = options.valueToColor( value ); + // tweaker buttons + if ( options.tweakersVisible ) { + plusButton.enabled = ( value < options.maxValue ); + minusButton.enabled = ( value > options.minValue ); + } + }; + var wavelengthListener = function( wavelength ) { + updateUI( wavelength ); + }; + valueProperty.link( wavelengthListener ); + + /* + * The horizontal bounds of the wavelength control changes as the slider knob is dragged. + * To prevent this, we determine the extents of the control's bounds at min and max values, + * then add an invisible horizontal strut. + */ + // determine bounds at min and max wavelength settings + updateUI( options.minValue ); + var minX = this.left; + updateUI( options.maxValue ); + var maxX = this.right; + + // restore the wavelength + updateUI( valueProperty.get() ); + + // add a horizontal strut + var strut = new Rectangle( minX, 0, maxX - minX, 1, { pickable: false } ); + this.addChild( strut ); + strut.moveToBack(); + + this.mutate( options ); + + // @private - called by dispose + this.disposeWavelengthSlider = function() { + valueDisplay && valueDisplay.dispose(); + plusButton && plusButton.dispose(); + minusButton && minusButton.dispose(); + valueProperty.unlink( wavelengthListener ); + self.disposeAccessibleSlider(); // dispose accessibility + }; + + // mix accessible slider functionality into HSlider + var rangeProperty = new Property( new Range( options.minValue, options.maxValue ) ); + this.initializeAccessibleSlider( valueProperty, rangeProperty, new BooleanProperty( true ), options ); + } + + sceneryPhet.register( 'SpectrumSlider', SpectrumSlider ); + + /** + * The slider thumb, origin at top center. + * + * @param {number} width + * @param {number} height + * @param {Object} [options] + * @constructor + */ + function Thumb( width, height, options ) { + + options = _.extend( { + stroke: 'black', + lineWidth: 1, + fill: 'black' + }, options ); + + // Set the radius of the arcs based on the height or width, whichever is smaller. + var radiusScale = 0.15; + var radius = ( width < height ) ? radiusScale * width : radiusScale * height; + + // Calculate some parameters of the upper triangles of the thumb for getting arc offsets. + var hypotenuse = Math.sqrt( Math.pow( 0.5 * width, 2 ) + Math.pow( 0.3 * height, 2 ) ); + var angle = Math.acos( width * 0.5 / hypotenuse ); + var heightOffset = radius * Math.sin( angle ); + + // Draw the thumb shape starting at the right upper corner of the pentagon below the arc, + // this way we can get the arc coordinates for the arc in this corner from the other side, + // which will be easier to calculate arcing from bottom to top. + var shape = new Shape() + .moveTo( 0.5 * width, 0.3 * height + heightOffset ) + .lineTo( 0.5 * width, height - radius ) + .arc( 0.5 * width - radius, height - radius, radius, 0, Math.PI / 2 ) + .lineTo( -0.5 * width + radius, height ) + .arc( -0.5 * width + radius, height - radius, radius, Math.PI / 2, Math.PI ) + .lineTo( -0.5 * width, 0.3 * height + heightOffset ) + .arc( -0.5 * width + radius, 0.3 * height + heightOffset, radius, Math.PI, Math.PI + angle ); + + // Save the coordinates for the point above the left side arc, for use on the other side. + var sideArcPoint = shape.getLastPoint(); + + shape.lineTo( 0, 0 ) + .lineTo( -sideArcPoint.x, sideArcPoint.y ) + .arc( 0.5 * width - radius, 0.3 * height + heightOffset, radius, -angle, 0 ) + .close(); + + Path.call( this, shape, options ); + } + + sceneryPhet.register( 'SpectrumSlider.Thumb', Thumb ); + + inherit( Path, Thumb ); + + /** + * Displays the value and units. + * + * @param {Property} valueProperty + * @param {function} valueToString converts value {number} to text {string} for display + * @param {Object} [options] + * @constructor + */ + function ValueDisplay( valueProperty, valueToString, options ) { + + Text.call( this, '?', options ); + + var self = this; + var valueObserver = function( value ) { + self.text = valueToString( value ); + }; + valueProperty.link( valueObserver ); + + // @private called by dispose + this.disposeValueDisplay = function() { + valueProperty.unlink( valueObserver ); + }; + } + + sceneryPhet.register( 'SpectrumSlider.ValueDisplay', ValueDisplay ); + + inherit( Text, ValueDisplay, { + + dispose: function() { + this.disposeValueDisplay(); + Text.prototype.dispose.call( this ); + } + } ); + + //TODO better name for this, that doesn't conflict with scenery cursor + /** + * Rectangular 'cursor' that appears in the track directly above the thumb. Origin is at top center. + * + * @param {number} width + * @param {number} height + * @param {Object} [options] + * @constructor + */ + function Cursor( width, height, options ) { + Rectangle.call( this, -width / 2, 0, width, height, options ); + } + + sceneryPhet.register( 'SpectrumSlider.Cursor', Cursor ); + + inherit( Rectangle, Cursor ); + + inherit( Node, SpectrumSlider, { + + // @public + dispose: function() { + this.disposeWavelengthSlider(); + Node.prototype.dispose.call( this ); + } + } ); + + // mix accessibility in + AccessibleSlider.mixInto( SpectrumSlider ); + + return SpectrumSlider; +} ); diff --git a/js/VisibleColor.js b/js/VisibleColor.js index 51814cfed..5b8e499c0 100644 --- a/js/VisibleColor.js +++ b/js/VisibleColor.js @@ -21,11 +21,15 @@ define( function( require ) { var COLOR_MATCH_DELTA = 2; // Two colors match if their RGB components each differ by less than this amount. var SPEED_OF_LIGHT = 299792458; // The speed of light in a vacuum in meters/second + var VIOLET_WAVELENGTH = 380; // nanometers + var RED_WAVELENGTH = 780; // nanometers var VisibleColor = { // public constants - MIN_WAVELENGTH: 380, - MAX_WAVELENGTH: 780, + MIN_WAVELENGTH: VIOLET_WAVELENGTH, // in nanometers + MAX_WAVELENGTH: RED_WAVELENGTH, // in nanometers + MIN_FREQUENCY: SPEED_OF_LIGHT / RED_WAVELENGTH * 1E9, // in Hz + MAX_FREQUENCY: SPEED_OF_LIGHT / VIOLET_WAVELENGTH * 1E9, // in Hz WHITE_WAVELENGTH: 0, /** diff --git a/js/WavelengthSlider.js b/js/WavelengthSlider.js index 144f6fcc5..b2dfab7a6 100644 --- a/js/WavelengthSlider.js +++ b/js/WavelengthSlider.js @@ -1,33 +1,18 @@ -// Copyright 2013-2017, University of Colorado Boulder +// Copyright 2018, University of Colorado Boulder /** - * WavelengthSlider is a slider-like control used for setting visible wavelength. + * Slider that shows a spectrum of colors for selecting a wavelength. * - * @author Chris Malley (PixelZoom, Inc.) + * @author Sam Reid (PhET Interactive Simulations) */ define( function( require ) { 'use strict'; // modules - var AccessibleSlider = require( 'SUN/accessibility/AccessibleSlider' ); - var ArrowButton = require( 'SUN/buttons/ArrowButton' ); - var BooleanProperty = require( 'AXON/BooleanProperty' ); - var Dimension2 = require( 'DOT/Dimension2' ); - var FocusHighlightFromNode = require( 'SCENERY/accessibility/FocusHighlightFromNode' ); var inherit = require( 'PHET_CORE/inherit' ); - var Node = require( 'SCENERY/nodes/Node' ); - var Path = require( 'SCENERY/nodes/Path' ); - var PhetFont = require( 'SCENERY_PHET/PhetFont' ); - var Property = require( 'AXON/Property' ); - var Range = require( 'DOT/Range' ); - var Rectangle = require( 'SCENERY/nodes/Rectangle' ); var sceneryPhet = require( 'SCENERY_PHET/sceneryPhet' ); - var Shape = require( 'KITE/Shape' ); - var SimpleDragHandler = require( 'SCENERY/input/SimpleDragHandler' ); - var SpectrumNode = require( 'SCENERY_PHET/SpectrumNode' ); + var SpectrumSlider = require( 'SCENERY_PHET/SpectrumSlider' ); var StringUtils = require( 'PHETCOMMON/util/StringUtils' ); - var Tandem = require( 'TANDEM/Tandem' ); - var Text = require( 'SCENERY/nodes/Text' ); var Util = require( 'DOT/Util' ); var VisibleColor = require( 'SCENERY_PHET/VisibleColor' ); @@ -42,386 +27,28 @@ define( function( require ) { */ function WavelengthSlider( wavelengthProperty, options ) { - var self = this; - // options that are specific to this type options = _.extend( { minWavelength: VisibleColor.MIN_WAVELENGTH, maxWavelength: VisibleColor.MAX_WAVELENGTH, - - // track - trackWidth: 150, - trackHeight: 30, - trackOpacity: 1, - trackBorderStroke: 'black', - - // thumb - thumbWidth: 35, - thumbHeight: 45, - thumbTouchAreaXDilation: 12, - thumbTouchAreaYDilation: 10, - thumbMouseAreaXDilation: 0, - thumbMouseAreaYDilation: 0, - - // value - valueFont: new PhetFont( 20 ), - valueFill: 'black', - valueVisible: true, - valueYSpacing: 2, // {number} space between value and top of track - - // tweakers - tweakersVisible: true, - tweakersXSpacing: 8, // {number} space between tweakers and track - maxTweakersHeight: 30, - tweakersTouchAreaXDilation: 7, - tweakersTouchAreaYDilation: 7, - tweakersMouseAreaXDilation: 0, - tweakersMouseAreaYDilation: 0, - - // cursor, the rectangle than follows the thumb in the track - cursorVisible: true, - cursorStroke: 'black', - - // phet-io - tandem: Tandem.required - - }, options ); - - // validate wavelengths - assert && assert( options.minWavelength < options.maxWavelength ); - assert && assert( options.minWavelength >= VisibleColor.MIN_WAVELENGTH && options.minWavelength <= VisibleColor.MAX_WAVELENGTH ); - assert && assert( options.maxWavelength >= VisibleColor.MIN_WAVELENGTH && options.maxWavelength <= VisibleColor.MAX_WAVELENGTH ); - - Node.call( this ); - - var track = new SpectrumNode( { - size: new Dimension2( options.trackWidth, options.trackHeight ), - minWavelength: options.minWavelength, - maxWavelength: options.maxWavelength, - opacity: options.trackOpacity, - cursor: 'pointer' - } ); - - /* - * Put a border around the track. - * We don't stroke the track itself because stroking the track will affect its bounds, - * and will thus affect the drag handle behavior. - * Having a separate border also gives subclasses a place to add markings (eg, tick marks) - * without affecting the track's bounds. - */ - var trackBorder = new Rectangle( 0, 0, track.width, track.height, { - stroke: options.trackBorderStroke, - lineWidth: 1, - pickable: false - } ); - - var valueDisplay; - if ( options.valueVisible ) { - valueDisplay = new ValueDisplay( wavelengthProperty, { - font: options.valueFont, - fill: options.valueFill, - bottom: track.top - options.valueYSpacing - } ); - } - - var cursor; - if ( options.cursorVisible ) { - cursor = new Cursor( 3, track.height, { - stroke: options.cursorStroke, - top: track.top - } ); - } - - var thumb = new Thumb( options.thumbWidth, options.thumbHeight, { - cursor: 'pointer', - top: track.bottom - } ); - - // thumb touchArea - if ( options.thumbTouchAreaXDilation || options.thumbTouchAreaYDilation ) { - thumb.touchArea = thumb.localBounds - .dilatedXY( options.thumbTouchAreaXDilation, options.thumbTouchAreaYDilation ) - .shiftedY( options.thumbTouchAreaYDilation ); - } - - // thumb mouseArea - if ( options.thumbMouseAreaXDilation || options.thumbMouseAreaYDilation ) { - thumb.mouseArea = thumb.localBounds - .dilatedXY( options.thumbMouseAreaXDilation, options.thumbMouseAreaYDilation ) - .shiftedY( options.thumbMouseAreaYDilation ); - } - - // tweaker buttons for single-unit increments - var plusButton; - var minusButton; - if ( options.tweakersVisible ) { - - plusButton = new ArrowButton( 'right', function() { - wavelengthProperty.set( wavelengthProperty.get() + 1 ); - }, { - left: track.right + options.tweakersXSpacing, - centerY: track.centerY, - maxHeight: options.maxTweakersHeight, - tandem: options.tandem.createTandem( 'plusButton' ) - } ); - - minusButton = new ArrowButton( 'left', function() { - wavelengthProperty.set( wavelengthProperty.get() - 1 ); - }, { - right: track.left - options.tweakersXSpacing, - centerY: track.centerY, - maxHeight: options.maxTweakersHeight, - tandem: options.tandem.createTandem( 'minusButton' ) - } ); - - // tweakers touchArea - plusButton.touchArea = plusButton.localBounds - .dilatedXY( options.tweakersTouchAreaXDilation, options.tweakersTouchAreaYDilation ) - .shiftedX( options.tweakersTouchAreaXDilation ); - minusButton.touchArea = minusButton.localBounds - .dilatedXY( options.tweakersTouchAreaXDilation, options.tweakersTouchAreaYDilation ) - .shiftedX( -options.tweakersTouchAreaXDilation ); - - // tweakers mouseArea - plusButton.mouseArea = plusButton.localBounds - .dilatedXY( options.tweakersMouseAreaXDilation, options.tweakersMouseAreaYDilation ) - .shiftedX( options.tweakersMouseAreaXDilation ); - minusButton.mouseArea = minusButton.localBounds - .dilatedXY( options.tweakersMouseAreaXDilation, options.tweakersMouseAreaYDilation ) - .shiftedX( -options.tweakersMouseAreaXDilation ); - } - - // rendering order - this.addChild( track ); - this.addChild( trackBorder ); - this.addChild( thumb ); - valueDisplay && this.addChild( valueDisplay ); - cursor && this.addChild( cursor ); - plusButton && this.addChild( plusButton ); - minusButton && this.addChild( minusButton ); - - // transforms between position and wavelength - var positionToWavelength = function( x ) { - return Math.floor( Util.clamp( Util.linear( 0, track.width, options.minWavelength, options.maxWavelength, x ), options.minWavelength, options.maxWavelength ) ); - }; - var wavelengthToPosition = function( wavelength ) { - return Math.floor( Util.clamp( Util.linear( options.minWavelength, options.maxWavelength, 0, track.width, wavelength ), 0, track.width ) ); - }; - - // click in the track to change the value, continue dragging if desired - var handleTrackEvent = function( event ) { - var x = thumb.globalToParentPoint( event.pointer.point ).x; - var wavelength = positionToWavelength( x ); - wavelengthProperty.set( wavelength ); - }; - - track.addInputListener( new SimpleDragHandler( { - - tandem: options.tandem.createTandem( 'trackInputListener' ), - - start: function( event, trail ) { - handleTrackEvent( event ); + valueToString: function( value ) { + return StringUtils.format( wavelengthSliderPattern0Wavelength1UnitsString, Util.toFixed( value, 0 ), unitsNmString ); }, - - drag: function( event, trail ) { - handleTrackEvent( event ); + valueToColor: function( value ) { + return VisibleColor.wavelengthToColor( value ); } - } ) ); - - // thumb drag handler - var clickXOffset = 0; // x-offset between initial click and thumb's origin - thumb.addInputListener( new SimpleDragHandler( { - - tandem: options.tandem.createTandem( 'thumbInputListener' ), - - allowTouchSnag: true, - - start: function( event ) { - clickXOffset = thumb.globalToParentPoint( event.pointer.point ).x - thumb.x; - }, - - drag: function( event ) { - var x = thumb.globalToParentPoint( event.pointer.point ).x - clickXOffset; - var value = positionToWavelength( x ); - wavelengthProperty.set( value ); - } - } ) ); - - // @public (a11y) - custom focus highlight that surrounds and moves with the thumb - this.focusHighlight = new FocusHighlightFromNode( thumb ); - - // sync with model - var updateUI = function( wavelength ) { - // positions - var x = wavelengthToPosition( wavelength ); - thumb.centerX = x; - self.focusHighlight.centerX = thumb.centerX; - if ( cursor ) { cursor.centerX = x; } - if ( valueDisplay ) { valueDisplay.centerX = x; } - // thumb color - thumb.fill = VisibleColor.wavelengthToColor( wavelength ); - // tweaker buttons - if ( options.tweakersVisible ) { - plusButton.enabled = ( wavelength < options.maxWavelength ); - minusButton.enabled = ( wavelength > options.minWavelength ); - } - }; - var wavelengthListener = function( wavelength ) { - updateUI( wavelength ); - }; - wavelengthProperty.link( wavelengthListener ); - - /* - * The horizontal bounds of the wavelength control changes as the slider knob is dragged. - * To prevent this, we determine the extents of the control's bounds at min and max values, - * then add an invisible horizontal strut. - */ - // determine bounds at min and max wavelength settings - updateUI( options.minWavelength ); - var minX = this.left; - updateUI( options.maxWavelength ); - var maxX = this.right; - - // restore the wavelength - updateUI( wavelengthProperty.get() ); - - // add a horizontal strut - var strut = new Rectangle( minX, 0, maxX - minX, 1, { pickable: false } ); - this.addChild( strut ); - strut.moveToBack(); - - this.mutate( options ); - - // @private - called by dispose - this.disposeWavelengthSlider = function() { - valueDisplay && valueDisplay.dispose(); - plusButton && plusButton.dispose(); - minusButton && minusButton.dispose(); - wavelengthProperty.unlink( wavelengthListener ); - self.disposeAccessibleSlider(); // dispose accessibility - }; - - // mix accessible slider functionality into HSlider - var rangeProperty = new Property( new Range( options.minWavelength, options.maxWavelength ) ); - this.initializeAccessibleSlider( wavelengthProperty, rangeProperty, new BooleanProperty( true ), options ); - } - - sceneryPhet.register( 'WavelengthSlider', WavelengthSlider ); - - /** - * The slider thumb, origin at top center. - * - * @param {number} width - * @param {number} height - * @param {Object} [options] - * @constructor - */ - function Thumb( width, height, options ) { - - options = _.extend( { - stroke: 'black', - lineWidth: 1, - fill: 'black' }, options ); + assert && assert( typeof options.minValue === 'undefined', 'minValue is supplied by WavelengthSlider' ); + assert && assert( typeof options.maxValue === 'undefined', 'maxValue is supplied by WavelengthSlider' ); + assert && assert( typeof options.createTrackNode === 'undefined', 'createTrackNode is supplied by WavelengthSlider' ); + options.minValue = options.minWavelength; + options.maxValue = options.maxWavelength; - // Set the radius of the arcs based on the height or width, whichever is smaller. - var radiusScale = 0.15; - var radius = ( width < height ) ? radiusScale * width : radiusScale * height; - - // Calculate some parameters of the upper triangles of the thumb for getting arc offsets. - var hypotenuse = Math.sqrt( Math.pow( 0.5 * width, 2 ) + Math.pow( 0.3 * height, 2 ) ); - var angle = Math.acos( width * 0.5 / hypotenuse ); - var heightOffset = radius * Math.sin( angle ); - - // Draw the thumb shape starting at the right upper corner of the pentagon below the arc, - // this way we can get the arc coordinates for the arc in this corner from the other side, - // which will be easier to calculate arcing from bottom to top. - var shape = new Shape() - .moveTo( 0.5 * width, 0.3 * height + heightOffset ) - .lineTo( 0.5 * width, height - radius ) - .arc( 0.5 * width - radius, height - radius, radius, 0, Math.PI / 2 ) - .lineTo( -0.5 * width + radius, height ) - .arc( -0.5 * width + radius, height - radius, radius, Math.PI / 2, Math.PI ) - .lineTo( -0.5 * width, 0.3 * height + heightOffset ) - .arc( -0.5 * width + radius, 0.3 * height + heightOffset, radius, Math.PI, Math.PI + angle ); - - // Save the coordinates for the point above the left side arc, for use on the other side. - var sideArcPoint = shape.getLastPoint(); - - shape.lineTo( 0, 0 ) - .lineTo( -sideArcPoint.x, sideArcPoint.y ) - .arc( 0.5 * width - radius, 0.3 * height + heightOffset, radius, -angle, 0 ) - .close(); - - Path.call( this, shape, options ); + SpectrumSlider.call( this, wavelengthProperty, options ); } - sceneryPhet.register( 'WavelengthSlider.Thumb', Thumb ); - - inherit( Path, Thumb ); - - /** - * Displays the value and units. - * - * @param {Property} valueProperty - * @param {Object} [options] - * @constructor - */ - function ValueDisplay( valueProperty, options ) { - - Text.call( this, '?', options ); - - var self = this; - var valueObserver = function( value ) { - self.text = StringUtils.format( wavelengthSliderPattern0Wavelength1UnitsString, Util.toFixed( value, 0 ), unitsNmString ); - }; - valueProperty.link( valueObserver ); - - // @private called by dispose - this.disposeValueDisplay = function() { - valueProperty.unlink( valueObserver ); - }; - } - - sceneryPhet.register( 'WavelengthSlider.ValueDisplay', ValueDisplay ); - - inherit( Text, ValueDisplay, { - - dispose: function() { - this.disposeValueDisplay(); - Text.prototype.dispose.call( this ); - } - } ); - - //TODO better name for this, that doesn't conflict with scenery cursor - /** - * Rectangular 'cursor' that appears in the track directly above the thumb. Origin is at top center. - * - * @param {number} width - * @param {number} height - * @param {Object} [options] - * @constructor - */ - function Cursor( width, height, options ) { - Rectangle.call( this, -width / 2, 0, width, height, options ); - } - - sceneryPhet.register( 'WavelengthSlider.Cursor', Cursor ); - - inherit( Rectangle, Cursor ); - - inherit( Node, WavelengthSlider, { - - // @public - dispose: function() { - this.disposeWavelengthSlider(); - Node.prototype.dispose.call( this ); - } - } ); - - // mix accessibility in - AccessibleSlider.mixInto( WavelengthSlider ); + sceneryPhet.register( 'WavelengthSlider', WavelengthSlider ); - return WavelengthSlider; -} ); + return inherit( SpectrumSlider, WavelengthSlider ); +} ); \ No newline at end of file diff --git a/js/demo/SlidersScreenView.js b/js/demo/SlidersScreenView.js index efa862a4a..110ecb21b 100644 --- a/js/demo/SlidersScreenView.js +++ b/js/demo/SlidersScreenView.js @@ -24,7 +24,10 @@ define( function( require ) { var sceneryPhetQueryParameters = require( 'SCENERY_PHET/sceneryPhetQueryParameters' ); var Text = require( 'SCENERY/nodes/Text' ); var VBox = require( 'SCENERY/nodes/VBox' ); + var VisibleColor = require( 'SCENERY_PHET/VisibleColor' ); var WavelengthSlider = require( 'SCENERY_PHET/WavelengthSlider' ); + var FrequencySlider = require( 'SCENERY_PHET/FrequencySlider' ); + var SpectrumSlider = require( 'SCENERY_PHET/SpectrumSlider' ); /** * @constructor @@ -39,7 +42,9 @@ define( function( require ) { * {function(Bounds2): Node} getNode - creates the scene graph for the demo */ { label: 'NumberControl', getNode: demoNumberControl }, - { label: 'WavelengthSlider', getNode: demoWavelengthSlider } + { label: 'WavelengthSlider', getNode: demoWavelengthSlider }, + { label: 'FrequencySlider', getNode: demoFrequencySlider }, + { label: 'SpectrumSlider', getNode: demoSpectrumSlider } ], { selectedDemoLabel: sceneryPhetQueryParameters.slider } ); @@ -89,11 +94,11 @@ define( function( require ) { // NumberControl with alternate layout provided by the client var numberControl4 = new NumberControl( 'Weight:', weightProperty, weightRange, _.extend( { layoutFunction: function( titleNode, numberDisplay, slider, leftArrowButton, rightArrowButton ) { - return new HBox( { - spacing: 8, - resize: false, // prevent sliders from causing a resize when thumb is at min or max - children: [ titleNode, numberDisplay, leftArrowButton, slider, rightArrowButton ] - } ); + return new HBox( { + spacing: 8, + resize: false, // prevent sliders from causing a resize when thumb is at min or max + children: [ titleNode, numberDisplay, leftArrowButton, slider, rightArrowButton ] + } ); } }, numberControlOptions ) ); @@ -116,5 +121,21 @@ define( function( require ) { } ); }; + // Creates a demo for FrequencySlider + var demoFrequencySlider = function( layoutBounds ) { + var frequencyProperty = new Property( VisibleColor.MIN_FREQUENCY ); + return new FrequencySlider( frequencyProperty, { + center: layoutBounds.center + } ); + }; + + // Creates a demo for SpectrumSlider + var demoSpectrumSlider = function( layoutBounds ) { + return new SpectrumSlider( new Property( 0.5 ), { + center: layoutBounds.center, + valueToString: function( value ) {return value.toFixed( 2 );} + } ); + }; + return inherit( DemosScreenView, SlidersScreenView ); } ); \ No newline at end of file diff --git a/scenery-phet-strings_en.json b/scenery-phet-strings_en.json index 2d47b54e2..b401016ca 100644 --- a/scenery-phet-strings_en.json +++ b/scenery-phet-strings_en.json @@ -5,9 +5,15 @@ "WavelengthSlider.pattern_0wavelength_1units": { "value": "{0} {1}" }, + "FrequencySlider.pattern_0frequency_1units": { + "value": "{0} {1}" + }, "units_nm": { "value": "nm" }, + "unitsTHz": { + "value": "THz" + }, "shortCircuit": { "value": "Short circuit!" },