phetsims / sun

User-interface components for PhET simulations, built on top of Scenery.
MIT License
4 stars 12 forks source link

Slider dynamic layout (sizable) #792

Closed jonathanolson closed 11 months ago

jonathanolson commented 1 year ago

Slider isn't yet a sizable component. This is blocking some work in my-solar-system and will block other dynamic layout in some sims.

I have some stashed changes in progress here.

jonathanolson commented 1 year ago

Progress:

image

There's a fun linear optimization problem hiding in plain sight (perhaps a high-performance iterative solution will work):

I think we can make the simplification of "take the lowest and highest value tick labels, those will be the limiting factor" (at least for our uses), and that somewhat simplifies the problem (we'll have at most three linear segments in the function).

I'd avoid all of this, except we want the bounds to match the preferred bounds precisely (to rounding error).

jonathanolson commented 1 year ago

Patch:

Index: dot/js/Bounds2.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/dot/js/Bounds2.ts b/dot/js/Bounds2.ts
--- a/dot/js/Bounds2.ts (revision 77a88f07dc3fd814b859a7cf17771071ba76ae81)
+++ b/dot/js/Bounds2.ts (date 1666158406716)
@@ -18,6 +18,7 @@
 import Vector2 from './Vector2.js';
 import dot from './dot.js';
 import Matrix3 from './Matrix3.js';
+import Range from './Range.js';
 import Pool, { TPoolable } from '../../phet-core/js/Pool.js';
 import Orientation from '../../phet-core/js/Orientation.js';

@@ -1083,6 +1084,42 @@
     return this.shiftXY( v.x, v.y );
   }

+  /**
+   * Returns the range of the x-values of this bounds.
+   */
+  public getXRange(): Range {
+    return new Range( this.minX, this.maxX );
+  }
+
+  /**
+   * Sets the x-range of this bounds.
+   */
+  public setXRange( range: Range ): Bounds2 {
+    return this.setMinMax( range.min, this.minY, range.max, this.maxY );
+  }
+
+  public get xRange(): Range { return this.getXRange(); }
+
+  public set xRange( range: Range ) { this.setXRange( range ); }
+
+  /**
+   * Returns the range of the y-values of this bounds.
+   */
+  public getYRange(): Range {
+    return new Range( this.minY, this.maxY );
+  }
+
+  /**
+   * Sets the y-range of this bounds.
+   */
+  public setYRange( range: Range ): Bounds2 {
+    return this.setMinMax( this.minX, range.min, this.maxX, range.max );
+  }
+
+  public get yRange(): Range { return this.getYRange(); }
+
+  public set yRange( range: Range ) { this.setYRange( range ); }
+
   /**
    * Find a point in the bounds closest to the specified point.
    *
Index: sun/js/DefaultSliderTrack.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/sun/js/DefaultSliderTrack.ts b/sun/js/DefaultSliderTrack.ts
--- a/sun/js/DefaultSliderTrack.ts  (revision b26b9a2d21d6f837b169e59a08e51df1c5cc51eb)
+++ b/sun/js/DefaultSliderTrack.ts  (date 1666148234955)
@@ -10,9 +10,11 @@
  * @author Jesse Greenberg (PhET Interactive Simulations)
  */

+import Multilink from '../../axon/js/Multilink.js';
 import TProperty from '../../axon/js/TProperty.js';
+import TReadOnlyProperty from '../../axon/js/TReadOnlyProperty.js';
 import Range from '../../dot/js/Range.js';
-import optionize from '../../phet-core/js/optionize.js';
+import optionize, { combineOptions } from '../../phet-core/js/optionize.js';
 import PickRequired from '../../phet-core/js/types/PickRequired.js';
 import { TPaint, Node, Rectangle } from '../../scenery/js/imports.js';
 import { default as SliderTrack, SliderTrackOptions } from './SliderTrack.js';
@@ -32,10 +34,9 @@

 export default class DefaultSliderTrack extends SliderTrack {

-  private readonly enabledTrack: Rectangle;
   private readonly disposeDefaultSliderTrack: () => void;

-  public constructor( valueProperty: TProperty<number>, range: Range, providedOptions?: DefaultSliderTrackOptions ) {
+  public constructor( valueProperty: TProperty<number>, range: Range | TReadOnlyProperty<Range>, providedOptions?: DefaultSliderTrackOptions ) {

     const options = optionize<DefaultSliderTrackOptions, SelfOptions, SliderTrackOptions>()( {
       fillEnabled: 'white',
@@ -48,7 +49,7 @@
     // Represents the disabled range of the slider, always visible and always the full range
     // of the slider so that when the enabled range changes we see the enabled sub-range on top of the
     // full range of the slider.
-    const disabledTrack = new Rectangle( 0, 0, options.size.width, options.size.height, {
+    const disabledTrack = new Rectangle( {
       fill: options.fillDisabled,
       stroke: options.stroke,
       lineWidth: options.lineWidth,
@@ -59,7 +60,7 @@

     // Will change size depending on the enabled range of the slider.  On top so that we can see
     // the enabled sub-range of the slider.
-    const enabledTrack = new Rectangle( 0, 0, options.size.width, options.size.height, {
+    const enabledTrack = new Rectangle( {
       fill: options.fillEnabled,
       stroke: options.stroke,
       lineWidth: options.lineWidth,
@@ -69,22 +70,28 @@
     const trackNode = new Node( {
       children: [ disabledTrack, enabledTrack ]
     } );
-    super( valueProperty, trackNode, range, options );
-
-    this.enabledTrack = enabledTrack;
+    super( valueProperty, trackNode, range, combineOptions<SliderTrackOptions>( {
+      // Historically, our stroke will overflow
+      leftVisualOverflow: options.stroke !== null ? options.lineWidth / 2 : 0,
+      rightVisualOverflow: options.stroke !== null ? options.lineWidth / 2 : 0
+    }, options ) );

     // when the enabled range changes gray out the unusable parts of the slider
-    const enabledRangeObserver = ( enabledRange: Range ) => {
-      const minViewCoordinate = this.valueToPosition.evaluate( enabledRange.min );
-      const maxViewCoordinate = this.valueToPosition.evaluate( enabledRange.max );
+    const updateMultilink = Multilink.multilink( [
+      options.enabledRangeProperty,
+      this.valueToPositionProperty,
+      this.sizeProperty
+    ], ( enabledRange, valueToPosition, size ) => {
+      const enabledMinX = valueToPosition.evaluate( enabledRange.min );
+      const enabledMaxX = valueToPosition.evaluate( enabledRange.max );

-      // update the geometry of the enabled track
-      const enabledWidth = maxViewCoordinate - minViewCoordinate;
-      this.enabledTrack.setRect( minViewCoordinate, 0, enabledWidth, this.size.height );
-    };
-    options.enabledRangeProperty.link( enabledRangeObserver ); // needs to be unlinked in dispose function
+      disabledTrack.setRect( 0, 0, size.width, size.height );
+      enabledTrack.setRect( enabledMinX, 0, enabledMaxX - enabledMinX, size.height );
+    } );

-    this.disposeDefaultSliderTrack = () => options.enabledRangeProperty.unlink( enabledRangeObserver );
+    this.disposeDefaultSliderTrack = () => {
+      updateMultilink.dispose();
+    };
   }

   public override dispose(): void {
Index: sun/js/Slider.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/sun/js/Slider.ts b/sun/js/Slider.ts
--- a/sun/js/Slider.ts  (revision b26b9a2d21d6f837b169e59a08e51df1c5cc51eb)
+++ b/sun/js/Slider.ts  (date 1666166899499)
@@ -22,7 +22,7 @@
 import optionize from '../../phet-core/js/optionize.js';
 import Orientation from '../../phet-core/js/Orientation.js';
 import swapObjectKeys from '../../phet-core/js/swapObjectKeys.js';
-import { DragListener, FocusHighlightFromNode, Node, NodeOptions, Path, SceneryConstants, TPaint } from '../../scenery/js/imports.js';
+import { DragListener, FocusHighlightFromNode, LayoutConstraint, ManualConstraint, Node, NodeOptions, Path, SceneryConstants, Sizable, TPaint } from '../../scenery/js/imports.js';
 import Tandem from '../../tandem/js/Tandem.js';
 import IOType from '../../tandem/js/types/IOType.js';
 import ValueChangeSoundPlayer, { ValueChangeSoundPlayerOptions } from '../../tambo/js/sound-generators/ValueChangeSoundPlayer.js';
@@ -34,6 +34,9 @@
 import PickOptional from '../../phet-core/js/types/PickOptional.js';
 import { LinkableElement } from '../../tandem/js/PhetioObject.js';
 import LinkableProperty from '../../axon/js/LinkableProperty.js';
+import Multilink from '../../axon/js/Multilink.js';
+import DerivedProperty from '../../axon/js/DerivedProperty.js';
+import TProperty from '../../axon/js/TProperty.js';

 // constants
 const VERTICAL_ROTATION = -Math.PI / 2;
@@ -123,7 +126,7 @@

 type TickOptions = Pick<SelfOptions, 'tickLabelSpacing' | 'majorTickLength' | 'majorTickStroke' | 'majorTickLineWidth' | 'minorTickLength' | 'minorTickStroke' | 'minorTickLineWidth'>;

-export default class Slider extends AccessibleSlider( Node, 0 ) {
+export default class Slider extends Sizable( AccessibleSlider( Node, 0 ) ) {

   public readonly enabledRangeProperty: TReadOnlyProperty<Range>;

@@ -145,6 +148,8 @@

   private readonly disposeSlider: () => void;

+  private readonly ticks: Tick[] = [];
+
   // This is a marker to indicate that we should create the actual default slider sound.
   public static DEFAULT_SOUND = new ValueChangeSoundPlayer( new Range( 0, 1 ) );

@@ -341,6 +346,9 @@
       );
     }

+    const trackSpacer = new Node();
+    sliderParts.push( trackSpacer );
+
     this.track = options.trackNode || new DefaultSliderTrack( valueProperty, range, {

       // propagate options that are specific to SliderTrack
@@ -363,15 +371,6 @@
       tandem: trackTandem
     } );

-    // Position the track horizontally
-    this.track.centerX = this.track.valueToPosition.evaluate( ( range.max + range.min ) / 2 );
-
-    // Dilate the local bounds horizontally so that it extends beyond where the thumb can reach.  This prevents layout
-    // asymmetry when the slider thumb is off the edges of the track.  See https://github.com/phetsims/sun/issues/282
-    if ( options.trackBoundsDilation ) {
-      this.track.localBounds = this.track.localBounds.dilatedX( thumb.width / 2 );
-    }
-
     // Add the track
     sliderParts.push( this.track );

@@ -423,7 +422,7 @@
         if ( this.enabledProperty.get() ) {
           const transform = listener.pressedTrail.subtrailTo( sliderPartsNode ).getTransform(); // we only want the transform to our parent
           const x = transform.inversePosition2( event.pointer.point ).x - clickXOffset;
-          this.proposedValue = this.track.valueToPosition.inverse( x );
+          this.proposedValue = this.track.valueToPositionProperty.value.inverse( x );

           const valueInRange = this.enabledRangeProperty.get().constrainValue( this.proposedValue );
           valueProperty.set( options.constrainValue( valueInRange ) );
@@ -450,10 +449,9 @@
     this.trackDragListener = this.track.dragListener;

     // update thumb position when value changes
-    const valueObserver = ( value: number ) => {
-      thumb.centerX = this.track.valueToPosition.evaluate( value );
-    };
-    valueProperty.link( valueObserver ); // must be unlinked in disposeSlider
+    const valueMultilink = Multilink.multilink( [ valueProperty, this.track.valueToPositionProperty ], ( value, valueToPosition ) => {
+      thumb.centerX = valueToPosition.evaluate( value );
+    } );

     // when the enabled range changes, the value to position linear function must change as well
     const enabledRangeObserver = ( enabledRange: Range ) => {
@@ -480,11 +478,15 @@
     };
     this.enabledRangeProperty.link( enabledRangeObserver ); // needs to be unlinked in dispose function

+    const constraint = new SliderConstraint( this, this.track, thumb, sliderPartsNode, options.orientation, trackSpacer, this.ticks );
+
     this.disposeSlider = () => {
+      constraint.dispose();
+
       thumb.dispose && thumb.dispose(); // in case a custom thumb is provided via options.thumbNode that doesn't implement dispose
       this.track.dispose && this.track.dispose();

-      valueProperty.unlink( valueObserver );
+      valueMultilink.dispose();
       ownsEnabledRangeProperty && this.enabledRangeProperty.dispose();
       thumbDragListener.dispose();
     };
@@ -526,6 +528,11 @@

   public override dispose(): void {
     this.disposeSlider();
+
+    this.ticks.forEach( tick => {
+      tick.dispose();
+    } );
+
     super.dispose();
   }

@@ -549,29 +556,7 @@
    * Adds a tick mark above the track.
    */
   private addTick( parent: Node, value: number, label: Node | undefined, length: number, stroke: TPaint, lineWidth: number ): void {
-    const labelX = this.track.valueToPosition.evaluate( value );
-
-    // ticks
-    const tick = new Path( new Shape()
-        .moveTo( labelX, this.track.top )
-        .lineTo( labelX, this.track.top - length ),
-      { stroke: stroke, lineWidth: lineWidth } );
-    parent.addChild( tick );
-
-    // label
-    if ( label ) {
-
-      // For a vertical slider, rotate labels opposite the rotation of the slider, so that they appear as expected.
-      if ( this.orientation === Orientation.VERTICAL ) {
-        label.rotation = -VERTICAL_ROTATION;
-      }
-      parent.addChild( label );
-      label.localBoundsProperty.link( () => {
-        label.centerX = tick.centerX;
-        label.bottom = tick.top - this.tickOptions.tickLabelSpacing;
-      } );
-      label.pickable = false;
-    }
+    this.ticks.push( new Tick( parent, value, label, length, stroke, lineWidth, this.tickOptions, this.orientation, this.track ) );
   }

   // Sets visibility of major ticks.
@@ -601,6 +586,173 @@
   public static SliderIO: IOType;
 }

+class Tick {
+
+  private readonly labelXProperty: TReadOnlyProperty<number>;
+
+  public readonly tickNode: Node;
+
+  private readonly manualConstraint?: ManualConstraint<Node[]>;
+
+  // NOTE: This could be cleaned up so we could remove ticks or do other nice things
+  public constructor(
+    private readonly parent: Node,
+    public readonly value: number,
+    private readonly label: Node | undefined,
+    length: number,
+    stroke: TPaint,
+    lineWidth: number,
+    tickOptions: Required<TickOptions>,
+    orientation: Orientation,
+    track: SliderTrack
+  ) {
+
+    this.labelXProperty = new DerivedProperty( [ track.valueToPositionProperty ], valueToPosition => valueToPosition.evaluate( value ) );
+
+    // ticks
+    this.tickNode = new Node();
+    parent.addChild( this.tickNode );
+
+    const tickPath = new Path( new Shape()
+        .moveTo( 0, track.top )
+        .lineTo( 0, track.top - length ),
+      { stroke: stroke, lineWidth: lineWidth } );
+
+    this.labelXProperty.link( x => {
+      tickPath.x = x;
+    } );
+
+    this.tickNode.addChild( tickPath );
+
+    // label
+    if ( label ) {
+
+      // For a vertical slider, rotate labels opposite the rotation of the slider, so that they appear as expected.
+      if ( orientation === Orientation.VERTICAL ) {
+        label.rotation = -VERTICAL_ROTATION;
+      }
+      this.tickNode.addChild( label );
+
+      this.manualConstraint = ManualConstraint.create( this.tickNode, [ tickPath, label ], ( tickProxy, labelProxy ) => {
+        labelProxy.centerX = tickProxy.centerX;
+        labelProxy.bottom = tickProxy.top - tickOptions.tickLabelSpacing;
+      } );
+
+      label.pickable = false;
+    }
+  }
+
+  public dispose(): void {
+    this.parent.removeChild( this.tickNode );
+
+    this.labelXProperty.dispose();
+    this.manualConstraint && this.manualConstraint.dispose();
+  }
+}
+
+class SliderConstraint extends LayoutConstraint {
+
+  private readonly preferredProperty: TProperty<number | null>;
+
+  public constructor(
+    private readonly slider: Slider,
+    private readonly track: SliderTrack,
+    private readonly thumb: Node,
+    private readonly sliderPartsNode: Node,
+    private readonly orientation: Orientation,
+    private readonly trackSpacer: Node,
+    private readonly ticks: Tick[]
+  ) {
+
+    super( slider );
+
+    // We need to make it sizable in both dimensions (VSlider vs HSlider), but we'll still want to make the opposite
+    // axis non-sizable (since it won't be sizable in both orientations at once).
+    if ( orientation === Orientation.HORIZONTAL ) {
+      slider.heightSizable = false;
+      this.preferredProperty = this.slider.localPreferredWidthProperty;
+    }
+    else {
+      slider.widthSizable = false;
+      this.preferredProperty = this.slider.localPreferredHeightProperty;
+    }
+    this.preferredProperty.lazyLink( this._updateLayoutListener );
+
+    // So range changes or minimum changes will trigger layouts (since they can move ticks)
+    this.track.minimumSizeValueToPositionProperty.lazyLink( this._updateLayoutListener );
+
+    this.addNode( track );
+
+    // TODO: thumb changes to layout? unlikely
+    // TODO: range changes that move ticks??? more likely, we need to handle well
+
+    this.layout();
+  }
+
+  protected override layout(): void {
+    super.layout();
+
+    const slider = this.slider;
+    const track = this.track;
+    const thumb = this.thumb;
+
+    // Dilate the local bounds horizontally so that it extends beyond where the thumb can reach.  This prevents layout
+    // asymmetry when the slider thumb is off the edges of the track.  See https://github.com/phetsims/sun/issues/282
+    this.trackSpacer.localBounds = track.localBounds.dilatedX( thumb.width / 2 );
+
+    assert && assert( track.minimumWidth !== null );
+
+    // Start with the size our minimum track would be WITH the added spacing for the thumb
+    // NOTE: will be mutated below
+    const minimumRange = new Range( -thumb.width / 2, track.minimumWidth! + thumb.width / 2 );
+
+    // We'll need to consider where the ticks would be IF we had our minimum size (since the ticks would presumably
+    // potentially be spaced closer together). So we'll check the bounds of each tick if it was at that location, and
+    // ensure that ticks are included in our minimum range (since tick labels may stick out past the track).
+    this.ticks.forEach( tick => {
+      // Where the tick will be if we have our minimum size
+      const tickMinimumPosition = track.minimumSizeValueToPositionProperty.value.evaluate( tick.value );
+
+      // Adjust the minimum range to include it.
+      const halfTickWidth = tick.tickNode.width / 2;
+      // The tick will be centered
+      minimumRange.includeRange( new Range( -halfTickWidth, halfTickWidth ).shifted( tickMinimumPosition ) );
+    } );
+
+    // NOTE: a tick to the left will "stick out" by Math.max( 0, ( tickWidth / 2 ) - trackWidth * ( tickValue - range.min ) / ( range.max - range.min ) )
+    // So for ticks with a tickWidth, tickValue:
+    // sticks out by Math.max( 0, tickWidth / 2 - A * ( tickValue - B ) )
+    //     where A = trackWidth / ( range.max - range.min ), B = range.min
+    // thus influence ends where tickWidth = 2 * A * ( tickValue - B )
+    // NOTE: THUS this means that different ticks can mathematically be the limiting factor for different trackWidths
+    // which makes this more of a pain to deal with analytically...
+    // TODO:::: ALL OF THIS
+
+    const minimumWidth = minimumRange.getLength();
+
+    const hackyExtraWidth = minimumWidth - track.minimumWidth!;
+    track.preferredWidth = ( slider.widthSizable && this.preferredProperty.value !== null )
+                           ? this.preferredProperty.value - hackyExtraWidth
+                           : track.minimumWidth;
+
+    // Set minimums at the end
+    if ( this.orientation === Orientation.HORIZONTAL ) {
+      slider.localMinimumWidth = minimumWidth;
+    }
+    else {
+      slider.localMinimumHeight = minimumWidth;
+    }
+  }
+
+  public override dispose(): void {
+    this.preferredProperty.unlink( this._updateLayoutListener );
+
+    this.track.minimumSizeValueToPositionProperty.unlink( this._updateLayoutListener );
+
+    super.dispose();
+  }
+}
+
 Slider.SliderIO = new IOType( 'SliderIO', {
   valueType: Slider,
   documentation: 'A traditional slider component, with a knob and possibly tick marks',
Index: sun/js/SliderTrack.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/sun/js/SliderTrack.ts b/sun/js/SliderTrack.ts
--- a/sun/js/SliderTrack.ts (revision b26b9a2d21d6f837b169e59a08e51df1c5cc51eb)
+++ b/sun/js/SliderTrack.ts (date 1666156255947)
@@ -10,19 +10,22 @@

 import TProperty from '../../axon/js/TProperty.js';
 import TReadOnlyProperty from '../../axon/js/TReadOnlyProperty.js';
-import Property from '../../axon/js/Property.js';
 import Dimension2 from '../../dot/js/Dimension2.js';
 import LinearFunction from '../../dot/js/LinearFunction.js';
 import Range from '../../dot/js/Range.js';
 import ValueChangeSoundPlayer, { ValueChangeSoundPlayerOptions } from '../../tambo/js/sound-generators/ValueChangeSoundPlayer.js';
 import optionize from '../../phet-core/js/optionize.js';
-import { DragListener, Node, NodeOptions, SceneryEvent, Trail } from '../../scenery/js/imports.js';
+import { DragListener, Node, NodeOptions, SceneryEvent, Trail, WidthSizable } from '../../scenery/js/imports.js';
 import Tandem from '../../tandem/js/Tandem.js';
 import sun from './sun.js';
 import Slider from './Slider.js';
 import { VoicingOnEndResponse } from './accessibility/AccessibleValueHandler.js';
+import TinyProperty from '../../axon/js/TinyProperty.js';
+import DerivedProperty from '../../axon/js/DerivedProperty.js';

 type SelfOptions = {
+  // NOTE: for backwards-compatibility, the size does NOT include the extent of the stroke, so the track will be larger
+  // than this size
   size?: Dimension2;

   // called when a drag sequence starts
@@ -50,16 +53,27 @@
   // Announces the voicing response at the end of an interaction. Used by AccessibleValueHandler, see
   // Slider for an example usage.
   voicingOnEndResponse?: VoicingOnEndResponse;
+
+  // Since our historical slider tracks extend PAST the 0,size range (e.g. with strokes), and this information is needed
+  // so we can control the size based on our preferredWidth. We'll need the size to be somewhat smaller than our
+  // preferredWidth
+  leftVisualOverflow?: number;
+  rightVisualOverflow?: number;
 };

 export type SliderTrackOptions = SelfOptions & NodeOptions;

-export default class SliderTrack extends Node {
+export default class SliderTrack extends WidthSizable( Node ) {

-  public readonly size: Dimension2;
+  protected readonly minimumSize: Dimension2;
+  protected readonly widthProperty: TReadOnlyProperty<number>;
+  protected readonly sizeProperty: TReadOnlyProperty<Dimension2>;

   // For use by Slider, maps the value along the range of the track to the position along the width of the track
-  public readonly valueToPosition: LinearFunction;
+  public readonly valueToPositionProperty: TReadOnlyProperty<LinearFunction>;
+
+  // For mapping things when we're at our minimum size (needed for minimum size computations in Slider).
+  public readonly minimumSizeValueToPositionProperty: TReadOnlyProperty<LinearFunction>;

   // public so that clients can access Properties of the DragListener that tell us about its state
   // See https://github.com/phetsims/sun/issues/680
@@ -67,20 +81,25 @@

   private readonly disposeSliderTrack: () => void;

-  public constructor( valueProperty: TProperty<number>, trackNode: Node, range: Range, providedOptions?: SliderTrackOptions ) {
+  public constructor( valueProperty: TProperty<number>, trackNode: Node, range: Range | TReadOnlyProperty<Range>, providedOptions?: SliderTrackOptions ) {
     super();

+    const rangeProperty = range instanceof Range ? new TinyProperty( range ) : range;
+
     const options = optionize<SliderTrackOptions, SelfOptions, NodeOptions>()( {
       size: new Dimension2( 100, 5 ),
       startDrag: _.noop, // called when a drag sequence starts
       drag: _.noop, // called at the beginning of a drag event, before any other drag work happens
       endDrag: _.noop, // called when a drag sequence ends
       constrainValue: _.identity, // called before valueProperty is set
-      enabledRangeProperty: new Property( new Range( range.min, range.max ) ), // Defaults to a constant range
+      enabledRangeProperty: rangeProperty,
       soundGenerator: Slider.DEFAULT_SOUND,
       valueChangeSoundGeneratorOptions: {},
       voicingOnEndResponse: _.noop,

+      leftVisualOverflow: 0,
+      rightVisualOverflow: 0,
+
       // phet-io
       tandem: Tandem.REQUIRED,
       tandemNameSuffix: 'TrackNode'
@@ -88,23 +107,41 @@

     // If no sound generator was provided, create the default.
     if ( options.soundGenerator === Slider.DEFAULT_SOUND ) {
-      options.soundGenerator = new ValueChangeSoundPlayer( range, options.valueChangeSoundGeneratorOptions || {} );
+      // NOTE: We'll want to update ValueChangeSoundPlayer for dynamic ranges if it's used more for that
+      options.soundGenerator = new ValueChangeSoundPlayer( rangeProperty.value, options.valueChangeSoundGeneratorOptions || {} );
     }
     else if ( options.soundGenerator === null ) {
       options.soundGenerator = ValueChangeSoundPlayer.NO_SOUND;
     }

-    this.size = options.size;
-    this.valueToPosition = new LinearFunction( range.min, range.max, 0, this.size.width, true /* clamp */ );
+    this.minimumSize = options.size;
+    this.minimumWidth = this.minimumSize.width;
+    this.widthProperty = new DerivedProperty( [ this.localPreferredWidthProperty ], localPreferredWidth => {
+      // Our preferred width should be subtracted out by the anticipated overflow, so that our size can be slightly
+      // smaller.
+      return (
+               localPreferredWidth === null
+               ? this.minimumSize.width
+               : Math.max( this.minimumSize.width, localPreferredWidth )
+             ) - options.leftVisualOverflow - options.rightVisualOverflow;
+    } );
+    this.sizeProperty = new DerivedProperty( [
+      this.widthProperty
+    ], width => new Dimension2( width, this.minimumSize.height ) );
+
+    this.valueToPositionProperty = new DerivedProperty( [ rangeProperty, this.widthProperty ], ( range, width ) => {
+      return new LinearFunction( range.min, range.max, 0, width, true /* clamp */ );
+    } );
+    this.minimumSizeValueToPositionProperty = new DerivedProperty( [ rangeProperty ], range => {
+      return new LinearFunction( range.min, range.max, 0, this.minimumSize.width, true /* clamp */ );
+    } );

     // click in the track to change the value, continue dragging if desired
     const handleTrackEvent = ( event: SceneryEvent, trail: Trail ) => {
-      assert && assert( this.valueToPosition, 'valueToPosition should be defined' );
-
       const oldValue = valueProperty.value;
       const transform = trail.subtrailTo( this ).getTransform();
       const x = transform.inversePosition2( event.pointer.point ).x;
-      const value = this.valueToPosition.inverse( x );
+      const value = this.valueToPositionProperty.value.inverse( x );
       const valueInRange = options.enabledRangeProperty.value.constrainValue( value );
       const newValue = options.constrainValue( valueInRange );
       valueProperty.set( newValue );
Index: dot/js/Range.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/dot/js/Range.ts b/dot/js/Range.ts
--- a/dot/js/Range.ts   (revision 77a88f07dc3fd814b859a7cf17771071ba76ae81)
+++ b/dot/js/Range.ts   (date 1666158560378)
@@ -92,10 +92,12 @@
   /**
    * Sets the minimum and maximum value of the range
    */
-  public setMinMax( min: number, max: number ): void {
+  public setMinMax( min: number, max: number ): this {
     assert && assert( min <= max, `max must be >= to min. min: ${min}, max: ${max}` );
     this._min = min;
     this._max = max;
+
+    return this;
   }

   /**
@@ -148,6 +150,65 @@
     return ( this._max > range.min ) && ( range.max > this._min );
   }

+  /**
+   * The smallest range that contains both this range and the input range, returned as a copy.
+   *
+   * This is the immutable form of the function includeRange(). This will return a new range, and will not modify
+   * this range.
+   */
+  public union( range: Range ): Range {
+    return new Range( // eslint-disable-line no-html-constructors
+      Math.min( this.min, range.min ),
+      Math.max( this.max, range.max )
+    );
+  }
+
+  /**
+   * The smallest range that is contained by both this range and the input range, returned as a copy.
+   *
+   * This is the immutable form of the function constrainRange(). This will return a new range, and will not modify
+   * this range.
+   */
+  public intersection( range: Range ): Range {
+    return new Range( // eslint-disable-line no-html-constructors
+      Math.max( this.min, range.min ),
+      Math.min( this.max, range.max )
+    );
+  }
+
+  /**
+   * Modifies this range so that it contains both its original range and the input range.
+   *
+   * This is the mutable form of the function union(). This will mutate (change) this range, in addition to returning
+   * this range itself.
+   */
+  public includeRange( range: Range ): Range {
+    return this.setMinMax(
+      Math.min( this.min, range.min ),
+      Math.max( this.max, range.max )
+    );
+  }
+
+  /**
+   * Modifies this range so that it is the largest range contained both in its original range and in the input range.
+   *
+   * This is the mutable form of the function intersection(). This will mutate (change) this range, in addition to returning
+   * this range itself.
+   */
+  public constrainRange( range: Range ): Range {
+    return this.setMinMax(
+      Math.max( this.min, range.min ),
+      Math.min( this.max, range.max )
+    );
+  }
+
+  /**
+   * Returns a new range that is the same as this range, but shifted by the specified amount.
+   */
+  public shifted( n: number ): Range {
+    return new Range( this.min + n, this.max + n ); // eslint-disable-line no-html-constructors
+  }
+
   /**
    * Converts the attributes of this range to a string
    */
jonathanolson commented 1 year ago

Current patch:

Index: dot/js/main.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/dot/js/main.js b/dot/js/main.js
--- a/dot/js/main.js    (revision 77a88f07dc3fd814b859a7cf17771071ba76ae81)
+++ b/dot/js/main.js    (date 1666198670256)
@@ -5,6 +5,7 @@
 import './Bounds2.js';
 import './Bounds3.js';
 import './Combination.js';
+import './CompletePiecewiseLinearFunction.js';
 import './Complex.js';
 import './ConvexHull2.js';
 import './DampedHarmonic.js';
Index: dot/js/Bounds2.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/dot/js/Bounds2.ts b/dot/js/Bounds2.ts
--- a/dot/js/Bounds2.ts (revision 77a88f07dc3fd814b859a7cf17771071ba76ae81)
+++ b/dot/js/Bounds2.ts (date 1666158406716)
@@ -18,6 +18,7 @@
 import Vector2 from './Vector2.js';
 import dot from './dot.js';
 import Matrix3 from './Matrix3.js';
+import Range from './Range.js';
 import Pool, { TPoolable } from '../../phet-core/js/Pool.js';
 import Orientation from '../../phet-core/js/Orientation.js';

@@ -1083,6 +1084,42 @@
     return this.shiftXY( v.x, v.y );
   }

+  /**
+   * Returns the range of the x-values of this bounds.
+   */
+  public getXRange(): Range {
+    return new Range( this.minX, this.maxX );
+  }
+
+  /**
+   * Sets the x-range of this bounds.
+   */
+  public setXRange( range: Range ): Bounds2 {
+    return this.setMinMax( range.min, this.minY, range.max, this.maxY );
+  }
+
+  public get xRange(): Range { return this.getXRange(); }
+
+  public set xRange( range: Range ) { this.setXRange( range ); }
+
+  /**
+   * Returns the range of the y-values of this bounds.
+   */
+  public getYRange(): Range {
+    return new Range( this.minY, this.maxY );
+  }
+
+  /**
+   * Sets the y-range of this bounds.
+   */
+  public setYRange( range: Range ): Bounds2 {
+    return this.setMinMax( this.minX, range.min, this.maxX, range.max );
+  }
+
+  public get yRange(): Range { return this.getYRange(); }
+
+  public set yRange( range: Range ) { this.setYRange( range ); }
+
   /**
    * Find a point in the bounds closest to the specified point.
    *
Index: sun/js/DefaultSliderTrack.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/sun/js/DefaultSliderTrack.ts b/sun/js/DefaultSliderTrack.ts
--- a/sun/js/DefaultSliderTrack.ts  (revision b26b9a2d21d6f837b169e59a08e51df1c5cc51eb)
+++ b/sun/js/DefaultSliderTrack.ts  (date 1666148234955)
@@ -10,9 +10,11 @@
  * @author Jesse Greenberg (PhET Interactive Simulations)
  */

+import Multilink from '../../axon/js/Multilink.js';
 import TProperty from '../../axon/js/TProperty.js';
+import TReadOnlyProperty from '../../axon/js/TReadOnlyProperty.js';
 import Range from '../../dot/js/Range.js';
-import optionize from '../../phet-core/js/optionize.js';
+import optionize, { combineOptions } from '../../phet-core/js/optionize.js';
 import PickRequired from '../../phet-core/js/types/PickRequired.js';
 import { TPaint, Node, Rectangle } from '../../scenery/js/imports.js';
 import { default as SliderTrack, SliderTrackOptions } from './SliderTrack.js';
@@ -32,10 +34,9 @@

 export default class DefaultSliderTrack extends SliderTrack {

-  private readonly enabledTrack: Rectangle;
   private readonly disposeDefaultSliderTrack: () => void;

-  public constructor( valueProperty: TProperty<number>, range: Range, providedOptions?: DefaultSliderTrackOptions ) {
+  public constructor( valueProperty: TProperty<number>, range: Range | TReadOnlyProperty<Range>, providedOptions?: DefaultSliderTrackOptions ) {

     const options = optionize<DefaultSliderTrackOptions, SelfOptions, SliderTrackOptions>()( {
       fillEnabled: 'white',
@@ -48,7 +49,7 @@
     // Represents the disabled range of the slider, always visible and always the full range
     // of the slider so that when the enabled range changes we see the enabled sub-range on top of the
     // full range of the slider.
-    const disabledTrack = new Rectangle( 0, 0, options.size.width, options.size.height, {
+    const disabledTrack = new Rectangle( {
       fill: options.fillDisabled,
       stroke: options.stroke,
       lineWidth: options.lineWidth,
@@ -59,7 +60,7 @@

     // Will change size depending on the enabled range of the slider.  On top so that we can see
     // the enabled sub-range of the slider.
-    const enabledTrack = new Rectangle( 0, 0, options.size.width, options.size.height, {
+    const enabledTrack = new Rectangle( {
       fill: options.fillEnabled,
       stroke: options.stroke,
       lineWidth: options.lineWidth,
@@ -69,22 +70,28 @@
     const trackNode = new Node( {
       children: [ disabledTrack, enabledTrack ]
     } );
-    super( valueProperty, trackNode, range, options );
-
-    this.enabledTrack = enabledTrack;
+    super( valueProperty, trackNode, range, combineOptions<SliderTrackOptions>( {
+      // Historically, our stroke will overflow
+      leftVisualOverflow: options.stroke !== null ? options.lineWidth / 2 : 0,
+      rightVisualOverflow: options.stroke !== null ? options.lineWidth / 2 : 0
+    }, options ) );

     // when the enabled range changes gray out the unusable parts of the slider
-    const enabledRangeObserver = ( enabledRange: Range ) => {
-      const minViewCoordinate = this.valueToPosition.evaluate( enabledRange.min );
-      const maxViewCoordinate = this.valueToPosition.evaluate( enabledRange.max );
+    const updateMultilink = Multilink.multilink( [
+      options.enabledRangeProperty,
+      this.valueToPositionProperty,
+      this.sizeProperty
+    ], ( enabledRange, valueToPosition, size ) => {
+      const enabledMinX = valueToPosition.evaluate( enabledRange.min );
+      const enabledMaxX = valueToPosition.evaluate( enabledRange.max );

-      // update the geometry of the enabled track
-      const enabledWidth = maxViewCoordinate - minViewCoordinate;
-      this.enabledTrack.setRect( minViewCoordinate, 0, enabledWidth, this.size.height );
-    };
-    options.enabledRangeProperty.link( enabledRangeObserver ); // needs to be unlinked in dispose function
+      disabledTrack.setRect( 0, 0, size.width, size.height );
+      enabledTrack.setRect( enabledMinX, 0, enabledMaxX - enabledMinX, size.height );
+    } );

-    this.disposeDefaultSliderTrack = () => options.enabledRangeProperty.unlink( enabledRangeObserver );
+    this.disposeDefaultSliderTrack = () => {
+      updateMultilink.dispose();
+    };
   }

   public override dispose(): void {
Index: sun/js/Slider.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/sun/js/Slider.ts b/sun/js/Slider.ts
--- a/sun/js/Slider.ts  (revision b26b9a2d21d6f837b169e59a08e51df1c5cc51eb)
+++ b/sun/js/Slider.ts  (date 1666208046418)
@@ -14,6 +14,7 @@
 import Property from '../../axon/js/Property.js';
 import ReadOnlyProperty from '../../axon/js/ReadOnlyProperty.js';
 import Dimension2 from '../../dot/js/Dimension2.js';
+import CompletePiecewiseLinearFunction from '../../dot/js/CompletePiecewiseLinearFunction.js';
 import Range from '../../dot/js/Range.js';
 import Utils from '../../dot/js/Utils.js';
 import { Shape } from '../../kite/js/imports.js';
@@ -22,7 +23,7 @@
 import optionize from '../../phet-core/js/optionize.js';
 import Orientation from '../../phet-core/js/Orientation.js';
 import swapObjectKeys from '../../phet-core/js/swapObjectKeys.js';
-import { DragListener, FocusHighlightFromNode, Node, NodeOptions, Path, SceneryConstants, TPaint } from '../../scenery/js/imports.js';
+import { DragListener, FocusHighlightFromNode, LayoutConstraint, ManualConstraint, Node, NodeOptions, Path, SceneryConstants, Sizable, TPaint } from '../../scenery/js/imports.js';
 import Tandem from '../../tandem/js/Tandem.js';
 import IOType from '../../tandem/js/types/IOType.js';
 import ValueChangeSoundPlayer, { ValueChangeSoundPlayerOptions } from '../../tambo/js/sound-generators/ValueChangeSoundPlayer.js';
@@ -34,6 +35,10 @@
 import PickOptional from '../../phet-core/js/types/PickOptional.js';
 import { LinkableElement } from '../../tandem/js/PhetioObject.js';
 import LinkableProperty from '../../axon/js/LinkableProperty.js';
+import Multilink from '../../axon/js/Multilink.js';
+import DerivedProperty from '../../axon/js/DerivedProperty.js';
+import TProperty from '../../axon/js/TProperty.js';
+import Vector2 from '../../dot/js/Vector2.js';

 // constants
 const VERTICAL_ROTATION = -Math.PI / 2;
@@ -123,7 +128,7 @@

 type TickOptions = Pick<SelfOptions, 'tickLabelSpacing' | 'majorTickLength' | 'majorTickStroke' | 'majorTickLineWidth' | 'minorTickLength' | 'minorTickStroke' | 'minorTickLineWidth'>;

-export default class Slider extends AccessibleSlider( Node, 0 ) {
+export default class Slider extends Sizable( AccessibleSlider( Node, 0 ) ) {

   public readonly enabledRangeProperty: TReadOnlyProperty<Range>;

@@ -145,6 +150,8 @@

   private readonly disposeSlider: () => void;

+  private readonly ticks: Tick[] = [];
+
   // This is a marker to indicate that we should create the actual default slider sound.
   public static DEFAULT_SOUND = new ValueChangeSoundPlayer( new Range( 0, 1 ) );

@@ -341,6 +348,9 @@
       );
     }

+    const trackSpacer = new Node();
+    sliderParts.push( trackSpacer );
+
     this.track = options.trackNode || new DefaultSliderTrack( valueProperty, range, {

       // propagate options that are specific to SliderTrack
@@ -363,15 +373,6 @@
       tandem: trackTandem
     } );

-    // Position the track horizontally
-    this.track.centerX = this.track.valueToPosition.evaluate( ( range.max + range.min ) / 2 );
-
-    // Dilate the local bounds horizontally so that it extends beyond where the thumb can reach.  This prevents layout
-    // asymmetry when the slider thumb is off the edges of the track.  See https://github.com/phetsims/sun/issues/282
-    if ( options.trackBoundsDilation ) {
-      this.track.localBounds = this.track.localBounds.dilatedX( thumb.width / 2 );
-    }
-
     // Add the track
     sliderParts.push( this.track );

@@ -423,7 +424,7 @@
         if ( this.enabledProperty.get() ) {
           const transform = listener.pressedTrail.subtrailTo( sliderPartsNode ).getTransform(); // we only want the transform to our parent
           const x = transform.inversePosition2( event.pointer.point ).x - clickXOffset;
-          this.proposedValue = this.track.valueToPosition.inverse( x );
+          this.proposedValue = this.track.valueToPositionProperty.value.inverse( x );

           const valueInRange = this.enabledRangeProperty.get().constrainValue( this.proposedValue );
           valueProperty.set( options.constrainValue( valueInRange ) );
@@ -450,10 +451,9 @@
     this.trackDragListener = this.track.dragListener;

     // update thumb position when value changes
-    const valueObserver = ( value: number ) => {
-      thumb.centerX = this.track.valueToPosition.evaluate( value );
-    };
-    valueProperty.link( valueObserver ); // must be unlinked in disposeSlider
+    const valueMultilink = Multilink.multilink( [ valueProperty, this.track.valueToPositionProperty ], ( value, valueToPosition ) => {
+      thumb.centerX = valueToPosition.evaluate( value );
+    } );

     // when the enabled range changes, the value to position linear function must change as well
     const enabledRangeObserver = ( enabledRange: Range ) => {
@@ -480,11 +480,15 @@
     };
     this.enabledRangeProperty.link( enabledRangeObserver ); // needs to be unlinked in dispose function

+    const constraint = new SliderConstraint( this, this.track, thumb, sliderPartsNode, options.orientation, trackSpacer, this.ticks );
+
     this.disposeSlider = () => {
+      constraint.dispose();
+
       thumb.dispose && thumb.dispose(); // in case a custom thumb is provided via options.thumbNode that doesn't implement dispose
       this.track.dispose && this.track.dispose();

-      valueProperty.unlink( valueObserver );
+      valueMultilink.dispose();
       ownsEnabledRangeProperty && this.enabledRangeProperty.dispose();
       thumbDragListener.dispose();
     };
@@ -526,6 +530,11 @@

   public override dispose(): void {
     this.disposeSlider();
+
+    this.ticks.forEach( tick => {
+      tick.dispose();
+    } );
+
     super.dispose();
   }

@@ -549,29 +558,7 @@
    * Adds a tick mark above the track.
    */
   private addTick( parent: Node, value: number, label: Node | undefined, length: number, stroke: TPaint, lineWidth: number ): void {
-    const labelX = this.track.valueToPosition.evaluate( value );
-
-    // ticks
-    const tick = new Path( new Shape()
-        .moveTo( labelX, this.track.top )
-        .lineTo( labelX, this.track.top - length ),
-      { stroke: stroke, lineWidth: lineWidth } );
-    parent.addChild( tick );
-
-    // label
-    if ( label ) {
-
-      // For a vertical slider, rotate labels opposite the rotation of the slider, so that they appear as expected.
-      if ( this.orientation === Orientation.VERTICAL ) {
-        label.rotation = -VERTICAL_ROTATION;
-      }
-      parent.addChild( label );
-      label.localBoundsProperty.link( () => {
-        label.centerX = tick.centerX;
-        label.bottom = tick.top - this.tickOptions.tickLabelSpacing;
-      } );
-      label.pickable = false;
-    }
+    this.ticks.push( new Tick( parent, value, label, length, stroke, lineWidth, this.tickOptions, this.orientation, this.track ) );
   }

   // Sets visibility of major ticks.
@@ -601,6 +588,218 @@
   public static SliderIO: IOType;
 }

+class Tick {
+
+  private readonly labelXProperty: TReadOnlyProperty<number>;
+
+  public readonly tickNode: Node;
+
+  private readonly manualConstraint?: ManualConstraint<Node[]>;
+
+  // NOTE: This could be cleaned up so we could remove ticks or do other nice things
+  public constructor(
+    private readonly parent: Node,
+    public readonly value: number,
+    private readonly label: Node | undefined,
+    length: number,
+    stroke: TPaint,
+    lineWidth: number,
+    tickOptions: Required<TickOptions>,
+    orientation: Orientation,
+    track: SliderTrack
+  ) {
+
+    this.labelXProperty = new DerivedProperty( [ track.valueToPositionProperty ], valueToPosition => valueToPosition.evaluate( value ) );
+
+    // ticks
+    this.tickNode = new Node();
+    parent.addChild( this.tickNode );
+
+    const tickPath = new Path( new Shape()
+        .moveTo( 0, track.top )
+        .lineTo( 0, track.top - length ),
+      { stroke: stroke, lineWidth: lineWidth } );
+
+    this.labelXProperty.link( x => {
+      tickPath.x = x;
+    } );
+
+    this.tickNode.addChild( tickPath );
+
+    // label
+    if ( label ) {
+
+      // For a vertical slider, rotate labels opposite the rotation of the slider, so that they appear as expected.
+      if ( orientation === Orientation.VERTICAL ) {
+        label.rotation = -VERTICAL_ROTATION;
+      }
+      this.tickNode.addChild( label );
+
+      this.manualConstraint = ManualConstraint.create( this.tickNode, [ tickPath, label ], ( tickProxy, labelProxy ) => {
+        labelProxy.centerX = tickProxy.centerX;
+        labelProxy.bottom = tickProxy.top - tickOptions.tickLabelSpacing;
+      } );
+
+      label.pickable = false;
+    }
+  }
+
+  public dispose(): void {
+    this.parent.removeChild( this.tickNode );
+
+    this.labelXProperty.dispose();
+    this.manualConstraint && this.manualConstraint.dispose();
+  }
+}
+
+class SliderConstraint extends LayoutConstraint {
+
+  private readonly preferredProperty: TProperty<number | null>;
+
+  public constructor(
+    private readonly slider: Slider,
+    private readonly track: SliderTrack,
+    private readonly thumb: Node,
+    private readonly sliderPartsNode: Node,
+    private readonly orientation: Orientation,
+    private readonly trackSpacer: Node,
+    private readonly ticks: Tick[]
+  ) {
+
+    super( slider );
+
+    // We need to make it sizable in both dimensions (VSlider vs HSlider), but we'll still want to make the opposite
+    // axis non-sizable (since it won't be sizable in both orientations at once).
+    if ( orientation === Orientation.HORIZONTAL ) {
+      slider.heightSizable = false;
+      this.preferredProperty = this.slider.localPreferredWidthProperty;
+    }
+    else {
+      slider.widthSizable = false;
+      this.preferredProperty = this.slider.localPreferredHeightProperty;
+    }
+    this.preferredProperty.lazyLink( this._updateLayoutListener );
+
+    // So range changes or minimum changes will trigger layouts (since they can move ticks)
+    this.track.rangeProperty.lazyLink( this._updateLayoutListener );
+
+    this.addNode( track );
+
+    // TODO: thumb changes to layout? unlikely
+    // TODO: range changes that move ticks??? more likely, we need to handle well
+
+    this.layout();
+  }
+
+  protected override layout(): void {
+    super.layout();
+
+    const slider = this.slider;
+    const track = this.track;
+    const thumb = this.thumb;
+
+    // Dilate the local bounds horizontally so that it extends beyond where the thumb can reach.  This prevents layout
+    // asymmetry when the slider thumb is off the edges of the track.  See https://github.com/phetsims/sun/issues/282
+    this.trackSpacer.localBounds = track.localBounds.dilatedX( thumb.width / 2 );
+
+    assert && assert( track.minimumWidth !== null );
+
+    const trackInteriorWidth = track.minimumWidth! - track.leftVisualOverflow - track.rightVisualOverflow;
+
+    const normalizeTickValue = ( value: number ) => {
+      return Utils.linear( track.rangeProperty.value.min, track.rangeProperty.value.max, 0, 1, value );
+    };
+
+    // Start with the size our minimum track would be WITH the added spacing for the thumb
+    // NOTE: will be mutated below
+    // TODO: note about visual overflow
+    const minimumRange = new Range( -thumb.width / 2 - track.leftVisualOverflow, track.minimumWidth! + thumb.width / 2 - track.leftVisualOverflow );
+
+    // We'll need to consider where the ticks would be IF we had our minimum size (since the ticks would presumably
+    // potentially be spaced closer together). So we'll check the bounds of each tick if it was at that location, and
+    // ensure that ticks are included in our minimum range (since tick labels may stick out past the track).
+    this.ticks.forEach( tick => {
+      // Where the tick will be if we have our minimum size
+      const tickMinimumPosition = trackInteriorWidth * normalizeTickValue( tick.value );
+
+      // Adjust the minimum range to include it.
+      const halfTickWidth = tick.tickNode.width / 2;
+      // The tick will be centered
+      minimumRange.includeRange( new Range( -halfTickWidth, halfTickWidth ).shifted( tickMinimumPosition ) );
+    } );
+
+    // NOTE: a tick to the left will "stick out" by Math.max( 0, ( tickWidth / 2 ) - trackWidth * ( tickValue - range.min ) / ( range.max - range.min ) )
+    // So for ticks with a tickWidth, tickValue:
+    // sticks out by Math.max( 0, tickWidth / 2 - A * ( tickValue - B ) )
+    //     where A = trackWidth / ( range.max - range.min ), B = range.min
+    // thus influence ends where tickWidth = 2 * A * ( tickValue - B )
+    // NOTE: THUS this means that different ticks can mathematically be the limiting factor for different trackWidths
+    // which makes this more of a pain to deal with analytically...
+    // TODO:::: ALL OF THIS
+    // Math.max( -thumb.width / 2, tickWidth / 2 - trackWidth * normalizedTickValue )
+    // Left: Min of:
+    //   -thumb.width / 2 - track.leftVisualOverflow
+    //   (for every tick) -tickWidth / 2 + trackWidth * normalizedTickValue
+    // Right: Max of:
+    //   trackWidth + thumb.width / 2 - track.leftVisualOverflow
+    //   (for every tick) tickWidth / 2 + trackWidth * normalizedTickValue
+    // TODO: rename every track width with exterior or interior
+
+
+
+    //   (for every tick) tickWidth / 2 + ( trackWidth - overflow ) * normalizedTickValue
+    //   (for every tick) tickWidth / 2 - overflow * normalizedTickValue + trackWidth * normalizedTickValue
+
+    const totalOverflow = track.leftVisualOverflow + track.rightVisualOverflow;
+
+    const trackWidthToFullWidthFunction = CompletePiecewiseLinearFunction.max(
+      // Right side
+      // TODO: note about visual overflow
+      CompletePiecewiseLinearFunction.linear( 1, thumb.width / 2 - track.leftVisualOverflow ),
+      ...this.ticks.map( tick => {
+        const normalizedTickValue = normalizeTickValue( tick.value );
+        return CompletePiecewiseLinearFunction.linear( normalizedTickValue, tick.tickNode.width / 2 - totalOverflow * normalizedTickValue );
+      } )
+    ).minus( CompletePiecewiseLinearFunction.min(
+      // Left side
+      CompletePiecewiseLinearFunction.constant( -thumb.width / 2 - track.leftVisualOverflow ),
+      ...this.ticks.map( tick => {
+        const normalizedTickValue = normalizeTickValue( tick.value );
+        return CompletePiecewiseLinearFunction.linear( normalizedTickValue, -tick.tickNode.width / 2 - totalOverflow * normalizedTickValue );
+      }
+    ) ) );
+
+    const fullWidthToTrackWidthFunction = trackWidthToFullWidthFunction.withXValues( [
+      track.minimumWidth! - 1,
+      track.minimumWidth!,
+      ...trackWidthToFullWidthFunction.points.map( point => point.x ).filter( x => x > track.minimumWidth! + 1e-10 )
+    ] ).inverted();
+
+    const minimumWidth = minimumRange.getLength();
+
+    // TODO: assert we're over our minimumWidth
+    track.preferredWidth = ( slider.widthSizable && this.preferredProperty.value !== null )
+                           ? Math.max( track.minimumWidth!, fullWidthToTrackWidthFunction.evaluate( this.preferredProperty.value ) )
+                           : track.minimumWidth;
+
+    // Set minimums at the end
+    if ( this.orientation === Orientation.HORIZONTAL ) {
+      slider.localMinimumWidth = minimumWidth;
+    }
+    else {
+      slider.localMinimumHeight = minimumWidth;
+    }
+  }
+
+  public override dispose(): void {
+    this.preferredProperty.unlink( this._updateLayoutListener );
+
+    this.track.rangeProperty.unlink( this._updateLayoutListener );
+
+    super.dispose();
+  }
+}
+
 Slider.SliderIO = new IOType( 'SliderIO', {
   valueType: Slider,
   documentation: 'A traditional slider component, with a knob and possibly tick marks',
Index: dot/js/CompletePiecewiseLinearFunction.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/dot/js/CompletePiecewiseLinearFunction.ts b/dot/js/CompletePiecewiseLinearFunction.ts
new file mode 100644
--- /dev/null   (date 1666205054584)
+++ b/dot/js/CompletePiecewiseLinearFunction.ts (date 1666205054584)
@@ -0,0 +1,174 @@
+// Copyright 2022, University of Colorado Boulder
+
+/**
+ * Describes a 1d complete (fully defined for any number) function, where values are extrapolated given the final end
+ * points
+ *
+ * @author Jonathan Olson <jonathan.olson@colorado.edu>
+ */
+
+import dot from './dot.js';
+import Utils from './Utils.js';
+import Vector2 from './Vector2.js';
+
+class CompletePiecewiseLinearFunction {
+
+  public points: Vector2[];
+
+  // Assumed to be sorted by x value, and continuous
+  public constructor( points: Vector2[] ) {
+    assert && assert( points.length > 0 );
+
+    // We're going to remove collinear points, so we create an extra copy
+    this.points = points.slice();
+
+    for ( let i = 0; i < this.points.length - 2; i++ ) {
+      const a = this.points[ i ];
+      const b = this.points[ i + 1 ];
+      const c = this.points[ i + 2 ];
+
+      if ( Utils.arePointsCollinear( a, b, c ) ) {
+        this.points.splice( i + 1, 1 );
+        i--;
+      }
+    }
+  }
+
+  public findMatchingPair( x: number ): [ Vector2, Vector2 ] {
+    assert && assert( this.points.length > 1 );
+
+    let i = 0;
+    while ( i < this.points.length - 2 && this.points[ i + 1 ].x < x ) {
+      i++;
+    }
+    return [ this.points[ i ], this.points[ i + 1 ] ];
+  }
+
+  public evaluate( x: number ): number {
+    if ( this.points.length === 1 ) {
+      return this.points[ 0 ].y;
+    }
+    else {
+      const [ leftPoint, rightPoint ] = this.findMatchingPair( x );
+
+      if ( leftPoint.x === x ) {
+        return leftPoint.y;
+      }
+      else if ( rightPoint.x === x ) {
+        return rightPoint.y;
+      }
+      else {
+        return Utils.linear( leftPoint.x, rightPoint.x, leftPoint.y, rightPoint.y, x );
+      }
+    }
+  }
+
+  private getCombinedXValues( other: CompletePiecewiseLinearFunction ): number[] {
+    // TODO: unique should be with an epsilon
+    return _.sortBy( _.uniq( this.points.map( point => point.x ).concat( other.points.map( point => point.x ) ) ) );
+  }
+
+  private getIntersectedXValues( other: CompletePiecewiseLinearFunction ): number[] {
+    const xValues = this.getCombinedXValues( other );
+    const newXValues: number[] = [];
+
+    const epsilon = 1e-10;
+
+    for ( let i = 0; i < xValues.length - 1; i++ ) {
+      const leftX = xValues[ i ];
+      const rightX = xValues[ i + 1 ];
+      const intersectionPoint = Utils.lineLineIntersection(
+        // Our line
+        new Vector2( leftX, this.evaluate( leftX ) ),
+        new Vector2( rightX, this.evaluate( rightX ) ),
+
+        // Other line
+        new Vector2( leftX, other.evaluate( leftX ) ),
+        new Vector2( rightX, other.evaluate( rightX ) )
+      );
+      if ( intersectionPoint &&
+           Math.abs( intersectionPoint.x - leftX ) > epsilon &&
+           Math.abs( intersectionPoint.x - rightX ) > epsilon &&
+           // If it's our first pair of points, don't filter out points that are on the left side of the left point
+           ( i === 0 || intersectionPoint.x > leftX ) &&
+           // If it's our last pair of points, don't filter out points that are on the right side of the right point
+           ( i === xValues.length - 2 || intersectionPoint.x < rightX )
+      ) {
+        newXValues.push( intersectionPoint.x );
+      }
+    }
+
+    const criticalXValues = _.sortBy( [ ...xValues, ...newXValues ] );
+
+    // TODO: unique should be with an epsilon
+    return [
+      criticalXValues[ 0 ] - 1,
+      ...criticalXValues,
+      criticalXValues[ criticalXValues.length - 1 ] + 1
+    ];
+  }
+
+  private binaryXOperation( other: CompletePiecewiseLinearFunction, operation: ( a: number, b: number ) => number, xValues: number[] ): CompletePiecewiseLinearFunction {
+    return new CompletePiecewiseLinearFunction( xValues.map( x => {
+      return new Vector2( x, operation( this.evaluate( x ), other.evaluate( x ) ) );
+    } ) );
+  }
+
+  private binaryPointwiseOperation( other: CompletePiecewiseLinearFunction, operation: ( a: number, b: number ) => number ): CompletePiecewiseLinearFunction {
+    return this.binaryXOperation( other, operation, this.getCombinedXValues( other ) );
+  }
+
+  private binaryIntersectingOperation( other: CompletePiecewiseLinearFunction, operation: ( a: number, b: number ) => number ): CompletePiecewiseLinearFunction {
+    return this.binaryXOperation( other, operation, this.getIntersectedXValues( other ) );
+  }
+
+  public plus( other: CompletePiecewiseLinearFunction ): CompletePiecewiseLinearFunction {
+    return this.binaryPointwiseOperation( other, ( a, b ) => a + b );
+  }
+
+  public minus( other: CompletePiecewiseLinearFunction ): CompletePiecewiseLinearFunction {
+    return this.binaryPointwiseOperation( other, ( a, b ) => a - b );
+  }
+
+  public min( other: CompletePiecewiseLinearFunction ): CompletePiecewiseLinearFunction {
+    return this.binaryIntersectingOperation( other, Math.min );
+  }
+
+  public max( other: CompletePiecewiseLinearFunction ): CompletePiecewiseLinearFunction {
+    return this.binaryIntersectingOperation( other, Math.max );
+  }
+
+  public withXValues( xValues: number[] ): CompletePiecewiseLinearFunction {
+    return new CompletePiecewiseLinearFunction( xValues.map( x => new Vector2( x, this.evaluate( x ) ) ) );
+  }
+
+  public inverted(): CompletePiecewiseLinearFunction {
+    // TODO: assertions for non-invertible functions (or non-monotonic functions) --- we want to reverse if slope is negative
+    return new CompletePiecewiseLinearFunction( this.points.map( point => new Vector2( point.y, point.x ) ) );
+  }
+
+  public static sum( ...functions: CompletePiecewiseLinearFunction[] ): CompletePiecewiseLinearFunction {
+    return functions.reduce( ( a, b ) => a.plus( b ) );
+  }
+
+  public static min( ...functions: CompletePiecewiseLinearFunction[] ): CompletePiecewiseLinearFunction {
+    return functions.reduce( ( a, b ) => a.min( b ) );
+  }
+
+  public static max( ...functions: CompletePiecewiseLinearFunction[] ): CompletePiecewiseLinearFunction {
+    return functions.reduce( ( a, b ) => a.max( b ) );
+  }
+
+  public static constant( y: number ): CompletePiecewiseLinearFunction {
+    return new CompletePiecewiseLinearFunction( [ new Vector2( 0, y ) ] );
+  }
+
+  // Represents the function ax+b
+  public static linear( a: number, b: number ): CompletePiecewiseLinearFunction {
+    return new CompletePiecewiseLinearFunction( [ new Vector2( 0, b ), new Vector2( 1, a + b ) ] );
+  }
+}
+
+dot.register( 'CompletePiecewiseLinearFunction', CompletePiecewiseLinearFunction );
+
+export default CompletePiecewiseLinearFunction;
\ No newline at end of file
Index: sun/js/SliderTrack.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/sun/js/SliderTrack.ts b/sun/js/SliderTrack.ts
--- a/sun/js/SliderTrack.ts (revision b26b9a2d21d6f837b169e59a08e51df1c5cc51eb)
+++ b/sun/js/SliderTrack.ts (date 1666206927150)
@@ -10,19 +10,22 @@

 import TProperty from '../../axon/js/TProperty.js';
 import TReadOnlyProperty from '../../axon/js/TReadOnlyProperty.js';
-import Property from '../../axon/js/Property.js';
 import Dimension2 from '../../dot/js/Dimension2.js';
 import LinearFunction from '../../dot/js/LinearFunction.js';
 import Range from '../../dot/js/Range.js';
 import ValueChangeSoundPlayer, { ValueChangeSoundPlayerOptions } from '../../tambo/js/sound-generators/ValueChangeSoundPlayer.js';
 import optionize from '../../phet-core/js/optionize.js';
-import { DragListener, Node, NodeOptions, SceneryEvent, Trail } from '../../scenery/js/imports.js';
+import { DragListener, Node, NodeOptions, SceneryEvent, Trail, WidthSizable } from '../../scenery/js/imports.js';
 import Tandem from '../../tandem/js/Tandem.js';
 import sun from './sun.js';
 import Slider from './Slider.js';
 import { VoicingOnEndResponse } from './accessibility/AccessibleValueHandler.js';
+import TinyProperty from '../../axon/js/TinyProperty.js';
+import DerivedProperty from '../../axon/js/DerivedProperty.js';

 type SelfOptions = {
+  // NOTE: for backwards-compatibility, the size does NOT include the extent of the stroke, so the track will be larger
+  // than this size
   size?: Dimension2;

   // called when a drag sequence starts
@@ -50,37 +53,58 @@
   // Announces the voicing response at the end of an interaction. Used by AccessibleValueHandler, see
   // Slider for an example usage.
   voicingOnEndResponse?: VoicingOnEndResponse;
+
+  // Since our historical slider tracks extend PAST the 0,size range (e.g. with strokes), and this information is needed
+  // so we can control the size based on our preferredWidth. We'll need the size to be somewhat smaller than our
+  // preferredWidth
+  leftVisualOverflow?: number;
+  rightVisualOverflow?: number;
 };

 export type SliderTrackOptions = SelfOptions & NodeOptions;

-export default class SliderTrack extends Node {
+export default class SliderTrack extends WidthSizable( Node ) {

-  public readonly size: Dimension2;
+  protected readonly minimumSize: Dimension2;
+  protected readonly widthProperty: TReadOnlyProperty<number>;
+  protected readonly sizeProperty: TReadOnlyProperty<Dimension2>;

   // For use by Slider, maps the value along the range of the track to the position along the width of the track
-  public readonly valueToPosition: LinearFunction;
+  public readonly valueToPositionProperty: TReadOnlyProperty<LinearFunction>;
+
+  // For mapping things when we're at our minimum size (needed for minimum size computations in Slider).
+  public readonly minimumSizeValueToPositionProperty: TReadOnlyProperty<LinearFunction>;

   // public so that clients can access Properties of the DragListener that tell us about its state
   // See https://github.com/phetsims/sun/issues/680
   public readonly dragListener: DragListener;

+  // (sun-internal)
+  public readonly rangeProperty: TReadOnlyProperty<Range>;
+  public readonly leftVisualOverflow: number;
+  public readonly rightVisualOverflow: number;
+
   private readonly disposeSliderTrack: () => void;

-  public constructor( valueProperty: TProperty<number>, trackNode: Node, range: Range, providedOptions?: SliderTrackOptions ) {
+  public constructor( valueProperty: TProperty<number>, trackNode: Node, range: Range | TReadOnlyProperty<Range>, providedOptions?: SliderTrackOptions ) {
     super();

+    this.rangeProperty = range instanceof Range ? new TinyProperty( range ) : range;
+
     const options = optionize<SliderTrackOptions, SelfOptions, NodeOptions>()( {
       size: new Dimension2( 100, 5 ),
       startDrag: _.noop, // called when a drag sequence starts
       drag: _.noop, // called at the beginning of a drag event, before any other drag work happens
       endDrag: _.noop, // called when a drag sequence ends
       constrainValue: _.identity, // called before valueProperty is set
-      enabledRangeProperty: new Property( new Range( range.min, range.max ) ), // Defaults to a constant range
+      enabledRangeProperty: this.rangeProperty,
       soundGenerator: Slider.DEFAULT_SOUND,
       valueChangeSoundGeneratorOptions: {},
       voicingOnEndResponse: _.noop,

+      leftVisualOverflow: 0,
+      rightVisualOverflow: 0,
+
       // phet-io
       tandem: Tandem.REQUIRED,
       tandemNameSuffix: 'TrackNode'
@@ -88,23 +112,44 @@

     // If no sound generator was provided, create the default.
     if ( options.soundGenerator === Slider.DEFAULT_SOUND ) {
-      options.soundGenerator = new ValueChangeSoundPlayer( range, options.valueChangeSoundGeneratorOptions || {} );
+      // NOTE: We'll want to update ValueChangeSoundPlayer for dynamic ranges if it's used more for that
+      options.soundGenerator = new ValueChangeSoundPlayer( this.rangeProperty.value, options.valueChangeSoundGeneratorOptions || {} );
     }
     else if ( options.soundGenerator === null ) {
       options.soundGenerator = ValueChangeSoundPlayer.NO_SOUND;
     }

-    this.size = options.size;
-    this.valueToPosition = new LinearFunction( range.min, range.max, 0, this.size.width, true /* clamp */ );
+    this.leftVisualOverflow = options.leftVisualOverflow;
+    this.rightVisualOverflow = options.rightVisualOverflow;
+
+    this.minimumSize = options.size;
+    this.minimumWidth = this.minimumSize.width;
+    this.widthProperty = new DerivedProperty( [ this.localPreferredWidthProperty ], localPreferredWidth => {
+      // Our preferred width should be subtracted out by the anticipated overflow, so that our size can be slightly
+      // smaller.
+      return (
+               localPreferredWidth === null
+               ? this.minimumSize.width
+               : Math.max( this.minimumSize.width, localPreferredWidth )
+             ) - options.leftVisualOverflow - options.rightVisualOverflow;
+    } );
+    this.sizeProperty = new DerivedProperty( [
+      this.widthProperty
+    ], width => new Dimension2( width, this.minimumSize.height ) );
+
+    this.valueToPositionProperty = new DerivedProperty( [ this.rangeProperty, this.widthProperty ], ( range, width ) => {
+      return new LinearFunction( range.min, range.max, 0, width, true /* clamp */ );
+    } );
+    this.minimumSizeValueToPositionProperty = new DerivedProperty( [ this.rangeProperty ], range => {
+      return new LinearFunction( range.min, range.max, 0, this.minimumSize.width, true /* clamp */ );
+    } );

     // click in the track to change the value, continue dragging if desired
     const handleTrackEvent = ( event: SceneryEvent, trail: Trail ) => {
-      assert && assert( this.valueToPosition, 'valueToPosition should be defined' );
-
       const oldValue = valueProperty.value;
       const transform = trail.subtrailTo( this ).getTransform();
       const x = transform.inversePosition2( event.pointer.point ).x;
-      const value = this.valueToPosition.inverse( x );
+      const value = this.valueToPositionProperty.value.inverse( x );
       const valueInRange = options.enabledRangeProperty.value.constrainValue( value );
       const newValue = options.constrainValue( valueInRange );
       valueProperty.set( newValue );
Index: dot/js/Range.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/dot/js/Range.ts b/dot/js/Range.ts
--- a/dot/js/Range.ts   (revision 77a88f07dc3fd814b859a7cf17771071ba76ae81)
+++ b/dot/js/Range.ts   (date 1666158560378)
@@ -92,10 +92,12 @@
   /**
    * Sets the minimum and maximum value of the range
    */
-  public setMinMax( min: number, max: number ): void {
+  public setMinMax( min: number, max: number ): this {
     assert && assert( min <= max, `max must be >= to min. min: ${min}, max: ${max}` );
     this._min = min;
     this._max = max;
+
+    return this;
   }

   /**
@@ -148,6 +150,65 @@
     return ( this._max > range.min ) && ( range.max > this._min );
   }

+  /**
+   * The smallest range that contains both this range and the input range, returned as a copy.
+   *
+   * This is the immutable form of the function includeRange(). This will return a new range, and will not modify
+   * this range.
+   */
+  public union( range: Range ): Range {
+    return new Range( // eslint-disable-line no-html-constructors
+      Math.min( this.min, range.min ),
+      Math.max( this.max, range.max )
+    );
+  }
+
+  /**
+   * The smallest range that is contained by both this range and the input range, returned as a copy.
+   *
+   * This is the immutable form of the function constrainRange(). This will return a new range, and will not modify
+   * this range.
+   */
+  public intersection( range: Range ): Range {
+    return new Range( // eslint-disable-line no-html-constructors
+      Math.max( this.min, range.min ),
+      Math.min( this.max, range.max )
+    );
+  }
+
+  /**
+   * Modifies this range so that it contains both its original range and the input range.
+   *
+   * This is the mutable form of the function union(). This will mutate (change) this range, in addition to returning
+   * this range itself.
+   */
+  public includeRange( range: Range ): Range {
+    return this.setMinMax(
+      Math.min( this.min, range.min ),
+      Math.max( this.max, range.max )
+    );
+  }
+
+  /**
+   * Modifies this range so that it is the largest range contained both in its original range and in the input range.
+   *
+   * This is the mutable form of the function intersection(). This will mutate (change) this range, in addition to returning
+   * this range itself.
+   */
+  public constrainRange( range: Range ): Range {
+    return this.setMinMax(
+      Math.max( this.min, range.min ),
+      Math.min( this.max, range.max )
+    );
+  }
+
+  /**
+   * Returns a new range that is the same as this range, but shifted by the specified amount.
+   */
+  public shifted( n: number ): Range {
+    return new Range( this.min + n, this.max + n ); // eslint-disable-line no-html-constructors
+  }
+
   /**
    * Converts the attributes of this range to a string
    */
jonathanolson commented 1 year ago

Latest patch, since I noticed the thumb width handling should be handled somewhat independently of the visual overflow. The thumb won't go out quite that far!

Index: dot/js/RangeWithValue.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/dot/js/RangeWithValue.ts b/dot/js/RangeWithValue.ts
--- a/dot/js/RangeWithValue.ts  (revision 77a88f07dc3fd814b859a7cf17771071ba76ae81)
+++ b/dot/js/RangeWithValue.ts  (date 1666282022289)
@@ -61,10 +61,10 @@
   /**
    * Setter for min and max
    */
-  public override setMinMax( min: number, max: number ): void {
+  public override setMinMax( min: number, max: number ): this {
     assert && assert( this._defaultValue >= min, `min must be <= defaultValue: ${min}` );
     assert && assert( this._defaultValue <= max, `max must be >= defaultValue: ${max}` );
-    super.setMinMax( min, max );
+    return super.setMinMax( min, max );
   }

   /**
Index: dot/js/main.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/dot/js/main.js b/dot/js/main.js
--- a/dot/js/main.js    (revision 77a88f07dc3fd814b859a7cf17771071ba76ae81)
+++ b/dot/js/main.js    (date 1666198670256)
@@ -5,6 +5,7 @@
 import './Bounds2.js';
 import './Bounds3.js';
 import './Combination.js';
+import './CompletePiecewiseLinearFunction.js';
 import './Complex.js';
 import './ConvexHull2.js';
 import './DampedHarmonic.js';
Index: dot/js/Bounds2.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/dot/js/Bounds2.ts b/dot/js/Bounds2.ts
--- a/dot/js/Bounds2.ts (revision 77a88f07dc3fd814b859a7cf17771071ba76ae81)
+++ b/dot/js/Bounds2.ts (date 1666158406716)
@@ -18,6 +18,7 @@
 import Vector2 from './Vector2.js';
 import dot from './dot.js';
 import Matrix3 from './Matrix3.js';
+import Range from './Range.js';
 import Pool, { TPoolable } from '../../phet-core/js/Pool.js';
 import Orientation from '../../phet-core/js/Orientation.js';

@@ -1083,6 +1084,42 @@
     return this.shiftXY( v.x, v.y );
   }

+  /**
+   * Returns the range of the x-values of this bounds.
+   */
+  public getXRange(): Range {
+    return new Range( this.minX, this.maxX );
+  }
+
+  /**
+   * Sets the x-range of this bounds.
+   */
+  public setXRange( range: Range ): Bounds2 {
+    return this.setMinMax( range.min, this.minY, range.max, this.maxY );
+  }
+
+  public get xRange(): Range { return this.getXRange(); }
+
+  public set xRange( range: Range ) { this.setXRange( range ); }
+
+  /**
+   * Returns the range of the y-values of this bounds.
+   */
+  public getYRange(): Range {
+    return new Range( this.minY, this.maxY );
+  }
+
+  /**
+   * Sets the y-range of this bounds.
+   */
+  public setYRange( range: Range ): Bounds2 {
+    return this.setMinMax( this.minX, range.min, this.maxX, range.max );
+  }
+
+  public get yRange(): Range { return this.getYRange(); }
+
+  public set yRange( range: Range ) { this.setYRange( range ); }
+
   /**
    * Find a point in the bounds closest to the specified point.
    *
Index: sun/js/DefaultSliderTrack.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/sun/js/DefaultSliderTrack.ts b/sun/js/DefaultSliderTrack.ts
--- a/sun/js/DefaultSliderTrack.ts  (revision b26b9a2d21d6f837b169e59a08e51df1c5cc51eb)
+++ b/sun/js/DefaultSliderTrack.ts  (date 1666148234955)
@@ -10,9 +10,11 @@
  * @author Jesse Greenberg (PhET Interactive Simulations)
  */

+import Multilink from '../../axon/js/Multilink.js';
 import TProperty from '../../axon/js/TProperty.js';
+import TReadOnlyProperty from '../../axon/js/TReadOnlyProperty.js';
 import Range from '../../dot/js/Range.js';
-import optionize from '../../phet-core/js/optionize.js';
+import optionize, { combineOptions } from '../../phet-core/js/optionize.js';
 import PickRequired from '../../phet-core/js/types/PickRequired.js';
 import { TPaint, Node, Rectangle } from '../../scenery/js/imports.js';
 import { default as SliderTrack, SliderTrackOptions } from './SliderTrack.js';
@@ -32,10 +34,9 @@

 export default class DefaultSliderTrack extends SliderTrack {

-  private readonly enabledTrack: Rectangle;
   private readonly disposeDefaultSliderTrack: () => void;

-  public constructor( valueProperty: TProperty<number>, range: Range, providedOptions?: DefaultSliderTrackOptions ) {
+  public constructor( valueProperty: TProperty<number>, range: Range | TReadOnlyProperty<Range>, providedOptions?: DefaultSliderTrackOptions ) {

     const options = optionize<DefaultSliderTrackOptions, SelfOptions, SliderTrackOptions>()( {
       fillEnabled: 'white',
@@ -48,7 +49,7 @@
     // Represents the disabled range of the slider, always visible and always the full range
     // of the slider so that when the enabled range changes we see the enabled sub-range on top of the
     // full range of the slider.
-    const disabledTrack = new Rectangle( 0, 0, options.size.width, options.size.height, {
+    const disabledTrack = new Rectangle( {
       fill: options.fillDisabled,
       stroke: options.stroke,
       lineWidth: options.lineWidth,
@@ -59,7 +60,7 @@

     // Will change size depending on the enabled range of the slider.  On top so that we can see
     // the enabled sub-range of the slider.
-    const enabledTrack = new Rectangle( 0, 0, options.size.width, options.size.height, {
+    const enabledTrack = new Rectangle( {
       fill: options.fillEnabled,
       stroke: options.stroke,
       lineWidth: options.lineWidth,
@@ -69,22 +70,28 @@
     const trackNode = new Node( {
       children: [ disabledTrack, enabledTrack ]
     } );
-    super( valueProperty, trackNode, range, options );
-
-    this.enabledTrack = enabledTrack;
+    super( valueProperty, trackNode, range, combineOptions<SliderTrackOptions>( {
+      // Historically, our stroke will overflow
+      leftVisualOverflow: options.stroke !== null ? options.lineWidth / 2 : 0,
+      rightVisualOverflow: options.stroke !== null ? options.lineWidth / 2 : 0
+    }, options ) );

     // when the enabled range changes gray out the unusable parts of the slider
-    const enabledRangeObserver = ( enabledRange: Range ) => {
-      const minViewCoordinate = this.valueToPosition.evaluate( enabledRange.min );
-      const maxViewCoordinate = this.valueToPosition.evaluate( enabledRange.max );
+    const updateMultilink = Multilink.multilink( [
+      options.enabledRangeProperty,
+      this.valueToPositionProperty,
+      this.sizeProperty
+    ], ( enabledRange, valueToPosition, size ) => {
+      const enabledMinX = valueToPosition.evaluate( enabledRange.min );
+      const enabledMaxX = valueToPosition.evaluate( enabledRange.max );

-      // update the geometry of the enabled track
-      const enabledWidth = maxViewCoordinate - minViewCoordinate;
-      this.enabledTrack.setRect( minViewCoordinate, 0, enabledWidth, this.size.height );
-    };
-    options.enabledRangeProperty.link( enabledRangeObserver ); // needs to be unlinked in dispose function
+      disabledTrack.setRect( 0, 0, size.width, size.height );
+      enabledTrack.setRect( enabledMinX, 0, enabledMaxX - enabledMinX, size.height );
+    } );

-    this.disposeDefaultSliderTrack = () => options.enabledRangeProperty.unlink( enabledRangeObserver );
+    this.disposeDefaultSliderTrack = () => {
+      updateMultilink.dispose();
+    };
   }

   public override dispose(): void {
Index: sun/js/Slider.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/sun/js/Slider.ts b/sun/js/Slider.ts
--- a/sun/js/Slider.ts  (revision b26b9a2d21d6f837b169e59a08e51df1c5cc51eb)
+++ b/sun/js/Slider.ts  (date 1666282745045)
@@ -14,6 +14,7 @@
 import Property from '../../axon/js/Property.js';
 import ReadOnlyProperty from '../../axon/js/ReadOnlyProperty.js';
 import Dimension2 from '../../dot/js/Dimension2.js';
+import CompletePiecewiseLinearFunction from '../../dot/js/CompletePiecewiseLinearFunction.js';
 import Range from '../../dot/js/Range.js';
 import Utils from '../../dot/js/Utils.js';
 import { Shape } from '../../kite/js/imports.js';
@@ -22,7 +23,7 @@
 import optionize from '../../phet-core/js/optionize.js';
 import Orientation from '../../phet-core/js/Orientation.js';
 import swapObjectKeys from '../../phet-core/js/swapObjectKeys.js';
-import { DragListener, FocusHighlightFromNode, Node, NodeOptions, Path, SceneryConstants, TPaint } from '../../scenery/js/imports.js';
+import { DragListener, FocusHighlightFromNode, LayoutConstraint, ManualConstraint, Node, NodeOptions, Path, SceneryConstants, Sizable, TPaint } from '../../scenery/js/imports.js';
 import Tandem from '../../tandem/js/Tandem.js';
 import IOType from '../../tandem/js/types/IOType.js';
 import ValueChangeSoundPlayer, { ValueChangeSoundPlayerOptions } from '../../tambo/js/sound-generators/ValueChangeSoundPlayer.js';
@@ -34,6 +35,9 @@
 import PickOptional from '../../phet-core/js/types/PickOptional.js';
 import { LinkableElement } from '../../tandem/js/PhetioObject.js';
 import LinkableProperty from '../../axon/js/LinkableProperty.js';
+import Multilink from '../../axon/js/Multilink.js';
+import DerivedProperty from '../../axon/js/DerivedProperty.js';
+import TProperty from '../../axon/js/TProperty.js';

 // constants
 const VERTICAL_ROTATION = -Math.PI / 2;
@@ -123,7 +127,7 @@

 type TickOptions = Pick<SelfOptions, 'tickLabelSpacing' | 'majorTickLength' | 'majorTickStroke' | 'majorTickLineWidth' | 'minorTickLength' | 'minorTickStroke' | 'minorTickLineWidth'>;

-export default class Slider extends AccessibleSlider( Node, 0 ) {
+export default class Slider extends Sizable( AccessibleSlider( Node, 0 ) ) {

   public readonly enabledRangeProperty: TReadOnlyProperty<Range>;

@@ -145,6 +149,8 @@

   private readonly disposeSlider: () => void;

+  private readonly ticks: Tick[] = [];
+
   // This is a marker to indicate that we should create the actual default slider sound.
   public static DEFAULT_SOUND = new ValueChangeSoundPlayer( new Range( 0, 1 ) );

@@ -341,6 +347,9 @@
       );
     }

+    const trackSpacer = new Node();
+    sliderParts.push( trackSpacer );
+
     this.track = options.trackNode || new DefaultSliderTrack( valueProperty, range, {

       // propagate options that are specific to SliderTrack
@@ -363,15 +372,6 @@
       tandem: trackTandem
     } );

-    // Position the track horizontally
-    this.track.centerX = this.track.valueToPosition.evaluate( ( range.max + range.min ) / 2 );
-
-    // Dilate the local bounds horizontally so that it extends beyond where the thumb can reach.  This prevents layout
-    // asymmetry when the slider thumb is off the edges of the track.  See https://github.com/phetsims/sun/issues/282
-    if ( options.trackBoundsDilation ) {
-      this.track.localBounds = this.track.localBounds.dilatedX( thumb.width / 2 );
-    }
-
     // Add the track
     sliderParts.push( this.track );

@@ -423,7 +423,7 @@
         if ( this.enabledProperty.get() ) {
           const transform = listener.pressedTrail.subtrailTo( sliderPartsNode ).getTransform(); // we only want the transform to our parent
           const x = transform.inversePosition2( event.pointer.point ).x - clickXOffset;
-          this.proposedValue = this.track.valueToPosition.inverse( x );
+          this.proposedValue = this.track.valueToPositionProperty.value.inverse( x );

           const valueInRange = this.enabledRangeProperty.get().constrainValue( this.proposedValue );
           valueProperty.set( options.constrainValue( valueInRange ) );
@@ -450,10 +450,9 @@
     this.trackDragListener = this.track.dragListener;

     // update thumb position when value changes
-    const valueObserver = ( value: number ) => {
-      thumb.centerX = this.track.valueToPosition.evaluate( value );
-    };
-    valueProperty.link( valueObserver ); // must be unlinked in disposeSlider
+    const valueMultilink = Multilink.multilink( [ valueProperty, this.track.valueToPositionProperty ], ( value, valueToPosition ) => {
+      thumb.centerX = valueToPosition.evaluate( value );
+    } );

     // when the enabled range changes, the value to position linear function must change as well
     const enabledRangeObserver = ( enabledRange: Range ) => {
@@ -480,11 +479,15 @@
     };
     this.enabledRangeProperty.link( enabledRangeObserver ); // needs to be unlinked in dispose function

+    const constraint = new SliderConstraint( this, this.track, thumb, sliderPartsNode, options.orientation, trackSpacer, this.ticks );
+
     this.disposeSlider = () => {
+      constraint.dispose();
+
       thumb.dispose && thumb.dispose(); // in case a custom thumb is provided via options.thumbNode that doesn't implement dispose
       this.track.dispose && this.track.dispose();

-      valueProperty.unlink( valueObserver );
+      valueMultilink.dispose();
       ownsEnabledRangeProperty && this.enabledRangeProperty.dispose();
       thumbDragListener.dispose();
     };
@@ -526,6 +529,11 @@

   public override dispose(): void {
     this.disposeSlider();
+
+    this.ticks.forEach( tick => {
+      tick.dispose();
+    } );
+
     super.dispose();
   }

@@ -549,29 +557,7 @@
    * Adds a tick mark above the track.
    */
   private addTick( parent: Node, value: number, label: Node | undefined, length: number, stroke: TPaint, lineWidth: number ): void {
-    const labelX = this.track.valueToPosition.evaluate( value );
-
-    // ticks
-    const tick = new Path( new Shape()
-        .moveTo( labelX, this.track.top )
-        .lineTo( labelX, this.track.top - length ),
-      { stroke: stroke, lineWidth: lineWidth } );
-    parent.addChild( tick );
-
-    // label
-    if ( label ) {
-
-      // For a vertical slider, rotate labels opposite the rotation of the slider, so that they appear as expected.
-      if ( this.orientation === Orientation.VERTICAL ) {
-        label.rotation = -VERTICAL_ROTATION;
-      }
-      parent.addChild( label );
-      label.localBoundsProperty.link( () => {
-        label.centerX = tick.centerX;
-        label.bottom = tick.top - this.tickOptions.tickLabelSpacing;
-      } );
-      label.pickable = false;
-    }
+    this.ticks.push( new Tick( parent, value, label, length, stroke, lineWidth, this.tickOptions, this.orientation, this.track ) );
   }

   // Sets visibility of major ticks.
@@ -601,6 +587,232 @@
   public static SliderIO: IOType;
 }

+class Tick {
+
+  private readonly labelXProperty: TReadOnlyProperty<number>;
+
+  public readonly tickNode: Node;
+
+  private readonly manualConstraint?: ManualConstraint<Node[]>;
+
+  // NOTE: This could be cleaned up so we could remove ticks or do other nice things
+  public constructor(
+    private readonly parent: Node,
+    public readonly value: number,
+    private readonly label: Node | undefined,
+    length: number,
+    stroke: TPaint,
+    lineWidth: number,
+    tickOptions: Required<TickOptions>,
+    orientation: Orientation,
+    track: SliderTrack
+  ) {
+
+    this.labelXProperty = new DerivedProperty( [ track.valueToPositionProperty ], valueToPosition => valueToPosition.evaluate( value ) );
+
+    // ticks
+    this.tickNode = new Node();
+    parent.addChild( this.tickNode );
+
+    const tickPath = new Path( new Shape()
+        .moveTo( 0, track.top )
+        .lineTo( 0, track.top - length ),
+      { stroke: stroke, lineWidth: lineWidth } );
+
+    this.labelXProperty.link( x => {
+      tickPath.x = x;
+    } );
+
+    this.tickNode.addChild( tickPath );
+
+    // label
+    if ( label ) {
+
+      // For a vertical slider, rotate labels opposite the rotation of the slider, so that they appear as expected.
+      if ( orientation === Orientation.VERTICAL ) {
+        label.rotation = -VERTICAL_ROTATION;
+      }
+      this.tickNode.addChild( label );
+
+      this.manualConstraint = ManualConstraint.create( this.tickNode, [ tickPath, label ], ( tickProxy, labelProxy ) => {
+        labelProxy.centerX = tickProxy.centerX;
+        labelProxy.bottom = tickProxy.top - tickOptions.tickLabelSpacing;
+      } );
+
+      label.pickable = false;
+    }
+  }
+
+  public dispose(): void {
+    this.parent.removeChild( this.tickNode );
+
+    this.labelXProperty.dispose();
+    this.manualConstraint && this.manualConstraint.dispose();
+  }
+}
+
+class SliderConstraint extends LayoutConstraint {
+
+  private readonly preferredProperty: TProperty<number | null>;
+
+  public constructor(
+    private readonly slider: Slider,
+    private readonly track: SliderTrack,
+    private readonly thumb: Node,
+    private readonly sliderPartsNode: Node,
+    private readonly orientation: Orientation,
+    private readonly trackSpacer: Node,
+    private readonly ticks: Tick[]
+  ) {
+
+    super( slider );
+
+    // We need to make it sizable in both dimensions (VSlider vs HSlider), but we'll still want to make the opposite
+    // axis non-sizable (since it won't be sizable in both orientations at once).
+    if ( orientation === Orientation.HORIZONTAL ) {
+      slider.heightSizable = false;
+      this.preferredProperty = this.slider.localPreferredWidthProperty;
+    }
+    else {
+      slider.widthSizable = false;
+      this.preferredProperty = this.slider.localPreferredHeightProperty;
+    }
+    this.preferredProperty.lazyLink( this._updateLayoutListener );
+
+    // So range changes or minimum changes will trigger layouts (since they can move ticks)
+    this.track.rangeProperty.lazyLink( this._updateLayoutListener );
+
+    // Thumb size changes should trigger layout, since we check the width of the thumb
+    // NOTE: This is ignoring thumb scale changing, but for performance/correctness it makes sense to avoid that for now
+    // so we can rule out infinite loops of thumb movement.
+    this.thumb.localBoundsProperty.lazyLink( this._updateLayoutListener );
+
+    this.addNode( track );
+
+    this.layout();
+  }
+
+  protected override layout(): void {
+    super.layout();
+
+    const slider = this.slider;
+    const track = this.track;
+    const thumb = this.thumb;
+
+    // Dilate the local bounds horizontally so that it extends beyond where the thumb can reach.  This prevents layout
+    // asymmetry when the slider thumb is off the edges of the track.  See https://github.com/phetsims/sun/issues/282
+    this.trackSpacer.localBounds = track.localBounds.dilatedX( thumb.width / 2 );
+
+    assert && assert( track.minimumWidth !== null );
+
+    // Our track's (exterior) minimum width will INCLUDE "visual overflow" e.g. stroke. The actual range used for
+    // computation of where the thumb/ticks go will be the "interior" width (excluding the visual overflow), e.g.
+    // without the stroke. We'll need to track and handle these separately, and only handle tick positioning based on
+    // the interior width.
+    const trackMinimumExteriorWidth = track.minimumWidth!;
+    const trackMinimumInteriorWidth = trackMinimumExteriorWidth - track.leftVisualOverflow - track.rightVisualOverflow;
+
+    // Takes a tick's value into the [0,1] range. This should be multiplied times the potential INTERIOR track width
+    // in order to get the position the tick should be at.
+    const normalizeTickValue = ( value: number ) => {
+      return Utils.linear( track.rangeProperty.value.min, track.rangeProperty.value.max, 0, 1, value );
+    };
+
+    // NOTE: Due to visual overflow, our track's range (including the thumb extension) will actually go from
+    // ( -thumb.width / 2 - track.leftVisualOverflow ) on the left to
+    // ( trackExteriorWidth + thumb.width / 2 + track.rightVisualOverflow ) on the right.
+
+    // Start with the size our minimum track would be WITH the added spacing for the thumb
+    // NOTE: will be mutated below
+    // TODO: note about visual overflow
+    const minimumRange = new Range( -thumb.width / 2 - track.leftVisualOverflow, trackMinimumExteriorWidth + thumb.width / 2 - track.leftVisualOverflow );
+
+    // We'll need to consider where the ticks would be IF we had our minimum size (since the ticks would presumably
+    // potentially be spaced closer together). So we'll check the bounds of each tick if it was at that location, and
+    // ensure that ticks are included in our minimum range (since tick labels may stick out past the track).
+    this.ticks.forEach( tick => {
+      // Where the tick will be if we have our minimum size
+      const tickMinimumPosition = trackMinimumInteriorWidth * normalizeTickValue( tick.value );
+
+      // Adjust the minimum range to include it.
+      const halfTickWidth = tick.tickNode.width / 2;
+      // The tick will be centered
+      minimumRange.includeRange( new Range( -halfTickWidth, halfTickWidth ).shifted( tickMinimumPosition ) );
+    } );
+
+    // NOTE: a tick to the left will "stick out" by Math.max( 0, ( tickWidth / 2 ) - trackWidth * ( tickValue - range.min ) / ( range.max - range.min ) )
+    // So for ticks with a tickWidth, tickValue:
+    // sticks out by Math.max( 0, tickWidth / 2 - A * ( tickValue - B ) )
+    //     where A = trackWidth / ( range.max - range.min ), B = range.min
+    // thus influence ends where tickWidth = 2 * A * ( tickValue - B )
+    // NOTE: THUS this means that different ticks can mathematically be the limiting factor for different trackWidths
+    // which makes this more of a pain to deal with analytically...
+    // TODO:::: ALL OF THIS
+    // Math.max( -thumb.width / 2, tickWidth / 2 - trackWidth * normalizedTickValue )
+    // Left: Min of:
+    //   -thumb.width / 2 - track.leftVisualOverflow
+    //   (for every tick) -tickWidth / 2 + trackWidth * normalizedTickValue
+    // Right: Max of:
+    //   trackWidth + thumb.width / 2 - track.leftVisualOverflow
+    //   (for every tick) tickWidth / 2 + trackWidth * normalizedTickValue
+    // TODO: rename every track width with exterior or interior
+
+
+
+    //   (for every tick) tickWidth / 2 + ( trackWidth - overflow ) * normalizedTickValue
+    //   (for every tick) tickWidth / 2 - overflow * normalizedTickValue + trackWidth * normalizedTickValue
+
+    const totalOverflow = track.leftVisualOverflow + track.rightVisualOverflow;
+
+    const trackWidthToFullWidthFunction = CompletePiecewiseLinearFunction.max(
+      // Right side
+      // TODO: note about visual overflow
+      CompletePiecewiseLinearFunction.linear( 1, thumb.width / 2 - track.leftVisualOverflow ),
+      ...this.ticks.map( tick => {
+        const normalizedTickValue = normalizeTickValue( tick.value );
+        return CompletePiecewiseLinearFunction.linear( normalizedTickValue, tick.tickNode.width / 2 - totalOverflow * normalizedTickValue );
+      } )
+    ).minus( CompletePiecewiseLinearFunction.min(
+      // Left side
+      CompletePiecewiseLinearFunction.constant( -thumb.width / 2 - track.leftVisualOverflow ),
+      ...this.ticks.map( tick => {
+        const normalizedTickValue = normalizeTickValue( tick.value );
+        return CompletePiecewiseLinearFunction.linear( normalizedTickValue, -tick.tickNode.width / 2 - totalOverflow * normalizedTickValue );
+      }
+    ) ) );
+
+    const fullWidthToTrackWidthFunction = trackWidthToFullWidthFunction.withXValues( [
+      trackMinimumExteriorWidth - 1,
+      trackMinimumExteriorWidth,
+      ...trackWidthToFullWidthFunction.points.map( point => point.x ).filter( x => x > trackMinimumExteriorWidth + 1e-10 )
+    ] ).inverted();
+
+    const minimumWidth = minimumRange.getLength();
+
+    // TODO: assert we're over our minimumWidth
+    track.preferredWidth = ( slider.widthSizable && this.preferredProperty.value !== null )
+                           ? Math.max( trackMinimumExteriorWidth, fullWidthToTrackWidthFunction.evaluate( this.preferredProperty.value ) )
+                           : track.minimumWidth;
+
+    // Set minimums at the end
+    if ( this.orientation === Orientation.HORIZONTAL ) {
+      slider.localMinimumWidth = minimumWidth;
+    }
+    else {
+      slider.localMinimumHeight = minimumWidth;
+    }
+  }
+
+  public override dispose(): void {
+    this.preferredProperty.unlink( this._updateLayoutListener );
+
+    this.track.rangeProperty.unlink( this._updateLayoutListener );
+    this.thumb.localBoundsProperty.unlink( this._updateLayoutListener );
+
+    super.dispose();
+  }
+}
+
 Slider.SliderIO = new IOType( 'SliderIO', {
   valueType: Slider,
   documentation: 'A traditional slider component, with a knob and possibly tick marks',
Index: dot/js/CompletePiecewiseLinearFunction.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/dot/js/CompletePiecewiseLinearFunction.ts b/dot/js/CompletePiecewiseLinearFunction.ts
new file mode 100644
--- /dev/null   (date 1666281838091)
+++ b/dot/js/CompletePiecewiseLinearFunction.ts (date 1666281838091)
@@ -0,0 +1,257 @@
+// Copyright 2022, University of Colorado Boulder
+
+/**
+ * Describes a 1d complete (fully defined for any number) function, where values are extrapolated given the final end
+ * points.
+ *
+ * E.g. if the points (0,0) and (1,1) are provided, it represents the function f(x) = x for ALL values, especially
+ * values outside of the range [0,1]. For example, f(6) = 6.
+ *
+ * If a single point is provided, it represents a constant function.
+ *
+ * @author Jonathan Olson <jonathan.olson@colorado.edu>
+ */
+
+import dot from './dot.js';
+import Utils from './Utils.js';
+import Vector2 from './Vector2.js';
+
+class CompletePiecewiseLinearFunction {
+
+  public points: Vector2[];
+
+  // Assumed to be sorted by x value, and continuous
+  public constructor( points: Vector2[] ) {
+    assert && assert( points.length > 0 );
+    assert && points.forEach( ( point, i ) => {
+      if ( i < points.length - 1 ) {
+        assert && assert( point.x < points[ i + 1 ].x,
+          'Points should be strictly increasing in x value (ordered by their x value)' );
+      }
+    } );
+
+    // We're going to remove collinear points, so we create an extra copy
+    this.points = points.slice();
+
+    // NOTE: The removal of collinear points helps improve performance, since we sometimes need to "expand" the number
+    // of points. Repeated minimums/maximums for many inputs could otherwise become quite slow.
+    for ( let i = 0; i < this.points.length - 2; i++ ) {
+      const a = this.points[ i ];
+      const b = this.points[ i + 1 ];
+      const c = this.points[ i + 2 ];
+
+      if ( Utils.arePointsCollinear( a, b, c ) ) {
+        this.points.splice( i + 1, 1 );
+        i--;
+      }
+    }
+  }
+
+  /**
+   * Returns the pair of points that the x value is defined by.
+   *
+   * NOTE: x may NOT be contained in these points, if it's either less than or greater than any points in the points
+   * list.
+   */
+  public findMatchingPair( x: number ): [ Vector2, Vector2 ] {
+    assert && assert( this.points.length > 1 );
+
+    let i = 0;
+    while ( i < this.points.length - 2 && this.points[ i + 1 ].x < x ) {
+      i++;
+    }
+    return [ this.points[ i ], this.points[ i + 1 ] ];
+  }
+
+  /**
+   * Evaluates the function at the given x value, e.g. returns f(x).
+   */
+  public evaluate( x: number ): number {
+    if ( this.points.length === 1 ) {
+      return this.points[ 0 ].y;
+    }
+    else {
+      const [ leftPoint, rightPoint ] = this.findMatchingPair( x );
+
+      if ( leftPoint.x === x ) {
+        return leftPoint.y;
+      }
+      else if ( rightPoint.x === x ) {
+        return rightPoint.y;
+      }
+      else {
+        return Utils.linear( leftPoint.x, rightPoint.x, leftPoint.y, rightPoint.y, x );
+      }
+    }
+  }
+
+  /**
+   * Returns the sorted unique x-values included in either this function or the other function.
+   */
+  private getCombinedXValues( other: CompletePiecewiseLinearFunction ): number[] {
+    return CompletePiecewiseLinearFunction.sortedUniqueEpsilon(
+      this.points.map( point => point.x ).concat( other.points.map( point => point.x ) )
+    );
+  }
+
+  /**
+   * Returns the sorted unique x-values either included in either this function or the other function, OR that result
+   * from the intersection of the two functions.
+   */
+  private getIntersectedXValues( other: CompletePiecewiseLinearFunction ): number[] {
+    const xValues = this.getCombinedXValues( other );
+    const newXValues: number[] = [];
+
+    for ( let i = 0; i < xValues.length - 1; i++ ) {
+      const leftX = xValues[ i ];
+      const rightX = xValues[ i + 1 ];
+      const intersectionPoint = Utils.lineLineIntersection(
+        // Our line
+        new Vector2( leftX, this.evaluate( leftX ) ),
+        new Vector2( rightX, this.evaluate( rightX ) ),
+
+        // Other line
+        new Vector2( leftX, other.evaluate( leftX ) ),
+        new Vector2( rightX, other.evaluate( rightX ) )
+      );
+      if ( intersectionPoint &&
+           // If it's our first pair of points, don't filter out points that are on the left side of the left point
+           ( i === 0 || intersectionPoint.x > leftX ) &&
+           // If it's our last pair of points, don't filter out points that are on the right side of the right point
+           ( i === xValues.length - 2 || intersectionPoint.x < rightX )
+      ) {
+        newXValues.push( intersectionPoint.x );
+      }
+    }
+
+    // Remove duplicate values above and sort them
+    const criticalXValues = CompletePiecewiseLinearFunction.sortedUniqueEpsilon( [
+      ...xValues,
+      ...newXValues
+    ] );
+
+    // To capture the slope at the start/end, we'll add extra points to guarantee this. If they're duplicated, they'll
+    // be removed during the collinear check on construction.
+    return [
+      criticalXValues[ 0 ] - 1,
+      ...criticalXValues,
+      criticalXValues[ criticalXValues.length - 1 ] + 1
+    ];
+  }
+
+  /**
+   * Returns a new function that's the result of applying the binary operation at the given x values.
+   */
+  private binaryXOperation( other: CompletePiecewiseLinearFunction, operation: ( a: number, b: number ) => number, xValues: number[] ): CompletePiecewiseLinearFunction {
+    return new CompletePiecewiseLinearFunction( xValues.map( x => {
+      return new Vector2( x, operation( this.evaluate( x ), other.evaluate( x ) ) );
+    } ) );
+  }
+
+  /**
+   * Returns a new function that's the result of applying the binary operation at the x values that already occur
+   * in each function.
+   */
+  private binaryPointwiseOperation( other: CompletePiecewiseLinearFunction, operation: ( a: number, b: number ) => number ): CompletePiecewiseLinearFunction {
+    return this.binaryXOperation( other, operation, this.getCombinedXValues( other ) );
+  }
+
+  /**
+   * Returns a new function that's the result of applying the binary operation at the x values that either occur in
+   * each function OR at the intersection of the two functions.
+   */
+  private binaryIntersectingOperation( other: CompletePiecewiseLinearFunction, operation: ( a: number, b: number ) => number ): CompletePiecewiseLinearFunction {
+    return this.binaryXOperation( other, operation, this.getIntersectedXValues( other ) );
+  }
+
+  /**
+   * Returns a CompletePiecewiseLinearFunction that's the result of adding the two functions.
+   */
+  public plus( other: CompletePiecewiseLinearFunction ): CompletePiecewiseLinearFunction {
+    return this.binaryPointwiseOperation( other, ( a, b ) => a + b );
+  }
+
+  /**
+   * Returns a CompletePiecewiseLinearFunction that's the result of subtracting the two functions.
+   */
+  public minus( other: CompletePiecewiseLinearFunction ): CompletePiecewiseLinearFunction {
+    return this.binaryPointwiseOperation( other, ( a, b ) => a - b );
+  }
+
+  /**
+   * Returns a CompletePiecewiseLinearFunction that's the result of taking the minimum of the two functions
+   */
+  public min( other: CompletePiecewiseLinearFunction ): CompletePiecewiseLinearFunction {
+    return this.binaryIntersectingOperation( other, Math.min );
+  }
+
+  /**
+   * Returns a CompletePiecewiseLinearFunction that's the result of taking the maximum of the two functions
+   */
+  public max( other: CompletePiecewiseLinearFunction ): CompletePiecewiseLinearFunction {
+    return this.binaryIntersectingOperation( other, Math.max );
+  }
+
+  /**
+   * Allows redefining or clamping/truncating the function by only representing it from the given x values
+   */
+  public withXValues( xValues: number[] ): CompletePiecewiseLinearFunction {
+    return new CompletePiecewiseLinearFunction( xValues.map( x => new Vector2( x, this.evaluate( x ) ) ) );
+  }
+
+  /**
+   * Returns an inverted form of the function (assuming it is monotonically increasing or monotonically decreasing)
+   */
+  public inverted(): CompletePiecewiseLinearFunction {
+    const points = this.points.map( point => new Vector2( point.y, point.x ) );
+
+    // NOTE: We'll rely on the constructor to make sure that the inverse is valid. Here we'll handle the monotonically
+    // decreasing case (which is invertible, just needs a reversal of points)
+    if ( points.length > 1 && points[ 0 ].x > points[ 1 ].x ) {
+      points.reverse();
+    }
+
+    return new CompletePiecewiseLinearFunction( points );
+  }
+
+  public static sum( ...functions: CompletePiecewiseLinearFunction[] ): CompletePiecewiseLinearFunction {
+    return functions.reduce( ( a, b ) => a.plus( b ) );
+  }
+
+  public static min( ...functions: CompletePiecewiseLinearFunction[] ): CompletePiecewiseLinearFunction {
+    return functions.reduce( ( a, b ) => a.min( b ) );
+  }
+
+  public static max( ...functions: CompletePiecewiseLinearFunction[] ): CompletePiecewiseLinearFunction {
+    return functions.reduce( ( a, b ) => a.max( b ) );
+  }
+
+  public static constant( y: number ): CompletePiecewiseLinearFunction {
+    return new CompletePiecewiseLinearFunction( [ new Vector2( 0, y ) ] );
+  }
+
+  // Represents the function ax+b
+  public static linear( a: number, b: number ): CompletePiecewiseLinearFunction {
+    return new CompletePiecewiseLinearFunction( [ new Vector2( 0, b ), new Vector2( 1, a + b ) ] );
+  }
+
+  /**
+   * Returns a sorted list of the input numbers, ensuring no duplicates within a specified epsilon value
+   */
+  private static sortedUniqueEpsilon( numbers: number[], epsilon = 1e-10 ): number[] {
+    numbers = _.sortBy( numbers );
+
+    for ( let i = 0; i < numbers.length - 1; i++ ) {
+      if ( Math.abs( numbers[ i ] - numbers[ i + 1 ] ) < epsilon ) {
+        numbers.splice( i, 1 );
+        i--;
+      }
+    }
+
+    return numbers;
+  }
+}
+
+dot.register( 'CompletePiecewiseLinearFunction', CompletePiecewiseLinearFunction );
+
+export default CompletePiecewiseLinearFunction;
\ No newline at end of file
Index: proportion-playground/js/common/model/billiards/BilliardsTable.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/proportion-playground/js/common/model/billiards/BilliardsTable.js b/proportion-playground/js/common/model/billiards/BilliardsTable.js
--- a/proportion-playground/js/common/model/billiards/BilliardsTable.js (revision 017cf4aceb414650f8270384a71ea18e500c8140)
+++ b/proportion-playground/js/common/model/billiards/BilliardsTable.js (date 1666281891414)
@@ -61,7 +61,7 @@
     this.lengthProperty = lengthProperty;

     // @public {NumberProperty} - Number of grid units horizontally
-    this.widthProperty = widthProperty;
+    this.internalWidthProperty = widthProperty;

     // @public {Property.<Vector2>} - The position of the ball in pixels
     this.ballPositionProperty = new Vector2Property( new Vector2( 0, 0 ), {
Index: optics-lab/js/optics-lab/view/ControlPanel.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/optics-lab/js/optics-lab/view/ControlPanel.js b/optics-lab/js/optics-lab/view/ControlPanel.js
--- a/optics-lab/js/optics-lab/view/ControlPanel.js (revision 43bb1ab0ccc7549efcc270c0ce717bb4ecfd15a3)
+++ b/optics-lab/js/optics-lab/view/ControlPanel.js (date 1666281891424)
@@ -111,7 +111,7 @@
     this.expandedProperty = new Property( true );
     this.nbrOfRaysProperty = new Property( 10 );
     this.spreadProperty = new Property( 20 );
-    this.widthProperty = new Property( 50 );
+    this.internalWidthProperty = new Property( 50 );
     this.colorProperty = new Property( 'white' );
     this.diameterProperty = new Property( 50 );
     this.radiusOfCurvatureProperty = new Property( 150 );
Index: sun/js/SliderTrack.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/sun/js/SliderTrack.ts b/sun/js/SliderTrack.ts
--- a/sun/js/SliderTrack.ts (revision b26b9a2d21d6f837b169e59a08e51df1c5cc51eb)
+++ b/sun/js/SliderTrack.ts (date 1666282107634)
@@ -10,19 +10,22 @@

 import TProperty from '../../axon/js/TProperty.js';
 import TReadOnlyProperty from '../../axon/js/TReadOnlyProperty.js';
-import Property from '../../axon/js/Property.js';
 import Dimension2 from '../../dot/js/Dimension2.js';
 import LinearFunction from '../../dot/js/LinearFunction.js';
 import Range from '../../dot/js/Range.js';
 import ValueChangeSoundPlayer, { ValueChangeSoundPlayerOptions } from '../../tambo/js/sound-generators/ValueChangeSoundPlayer.js';
 import optionize from '../../phet-core/js/optionize.js';
-import { DragListener, Node, NodeOptions, SceneryEvent, Trail } from '../../scenery/js/imports.js';
+import { DragListener, Node, NodeOptions, SceneryEvent, Trail, WidthSizable } from '../../scenery/js/imports.js';
 import Tandem from '../../tandem/js/Tandem.js';
 import sun from './sun.js';
 import Slider from './Slider.js';
 import { VoicingOnEndResponse } from './accessibility/AccessibleValueHandler.js';
+import TinyProperty from '../../axon/js/TinyProperty.js';
+import DerivedProperty from '../../axon/js/DerivedProperty.js';

 type SelfOptions = {
+  // NOTE: for backwards-compatibility, the size does NOT include the extent of the stroke, so the track will be larger
+  // than this size
   size?: Dimension2;

   // called when a drag sequence starts
@@ -50,37 +53,55 @@
   // Announces the voicing response at the end of an interaction. Used by AccessibleValueHandler, see
   // Slider for an example usage.
   voicingOnEndResponse?: VoicingOnEndResponse;
+
+  // Since our historical slider tracks extend PAST the 0,size range (e.g. with strokes), and this information is needed
+  // so we can control the size based on our preferredWidth. We'll need the size to be somewhat smaller than our
+  // preferredWidth
+  leftVisualOverflow?: number;
+  rightVisualOverflow?: number;
 };

 export type SliderTrackOptions = SelfOptions & NodeOptions;

-export default class SliderTrack extends Node {
+export default class SliderTrack extends WidthSizable( Node ) {

-  public readonly size: Dimension2;
+  protected readonly minimumSize: Dimension2;
+  protected readonly internalWidthProperty: TReadOnlyProperty<number>;
+  protected readonly sizeProperty: TReadOnlyProperty<Dimension2>;

   // For use by Slider, maps the value along the range of the track to the position along the width of the track
-  public readonly valueToPosition: LinearFunction;
+  public readonly valueToPositionProperty: TReadOnlyProperty<LinearFunction>;

   // public so that clients can access Properties of the DragListener that tell us about its state
   // See https://github.com/phetsims/sun/issues/680
   public readonly dragListener: DragListener;

+  // (sun-internal)
+  public readonly rangeProperty: TReadOnlyProperty<Range>;
+  public readonly leftVisualOverflow: number;
+  public readonly rightVisualOverflow: number;
+
   private readonly disposeSliderTrack: () => void;

-  public constructor( valueProperty: TProperty<number>, trackNode: Node, range: Range, providedOptions?: SliderTrackOptions ) {
+  public constructor( valueProperty: TProperty<number>, trackNode: Node, range: Range | TReadOnlyProperty<Range>, providedOptions?: SliderTrackOptions ) {
     super();

+    this.rangeProperty = range instanceof Range ? new TinyProperty( range ) : range;
+
     const options = optionize<SliderTrackOptions, SelfOptions, NodeOptions>()( {
       size: new Dimension2( 100, 5 ),
       startDrag: _.noop, // called when a drag sequence starts
       drag: _.noop, // called at the beginning of a drag event, before any other drag work happens
       endDrag: _.noop, // called when a drag sequence ends
       constrainValue: _.identity, // called before valueProperty is set
-      enabledRangeProperty: new Property( new Range( range.min, range.max ) ), // Defaults to a constant range
+      enabledRangeProperty: this.rangeProperty,
       soundGenerator: Slider.DEFAULT_SOUND,
       valueChangeSoundGeneratorOptions: {},
       voicingOnEndResponse: _.noop,

+      leftVisualOverflow: 0,
+      rightVisualOverflow: 0,
+
       // phet-io
       tandem: Tandem.REQUIRED,
       tandemNameSuffix: 'TrackNode'
@@ -88,23 +109,43 @@

     // If no sound generator was provided, create the default.
     if ( options.soundGenerator === Slider.DEFAULT_SOUND ) {
-      options.soundGenerator = new ValueChangeSoundPlayer( range, options.valueChangeSoundGeneratorOptions || {} );
+      // NOTE: We'll want to update ValueChangeSoundPlayer for dynamic ranges if it's used more for that
+      options.soundGenerator = new ValueChangeSoundPlayer( this.rangeProperty.value, options.valueChangeSoundGeneratorOptions || {} );
     }
     else if ( options.soundGenerator === null ) {
       options.soundGenerator = ValueChangeSoundPlayer.NO_SOUND;
     }

-    this.size = options.size;
-    this.valueToPosition = new LinearFunction( range.min, range.max, 0, this.size.width, true /* clamp */ );
+    this.leftVisualOverflow = options.leftVisualOverflow;
+    this.rightVisualOverflow = options.rightVisualOverflow;
+
+    this.minimumSize = options.size;
+    this.minimumWidth = this.minimumSize.width;
+    this.internalWidthProperty = new DerivedProperty( [ this.localPreferredWidthProperty ], localPreferredWidth => {
+      // Our preferred width should be subtracted out by the anticipated overflow, so that our size can be slightly
+      // smaller.
+      return (
+               localPreferredWidth === null
+               ? this.minimumSize.width
+               : Math.max( this.minimumSize.width, localPreferredWidth )
+             ) - options.leftVisualOverflow - options.rightVisualOverflow;
+    } );
+    this.sizeProperty = new DerivedProperty( [
+      this.internalWidthProperty
+    ], width => new Dimension2( width, this.minimumSize.height ) );
+
+    // NOTE: Slider needs to make a lot of assumptions about how this works (in order to figure out proper layout).
+    // DO NOT change without taking a CLOSE CLOSE look at Slider's layout code.
+    this.valueToPositionProperty = new DerivedProperty( [ this.rangeProperty, this.internalWidthProperty ], ( range, width ) => {
+      return new LinearFunction( range.min, range.max, 0, width, true /* clamp */ );
+    } );

     // click in the track to change the value, continue dragging if desired
     const handleTrackEvent = ( event: SceneryEvent, trail: Trail ) => {
-      assert && assert( this.valueToPosition, 'valueToPosition should be defined' );
-
       const oldValue = valueProperty.value;
       const transform = trail.subtrailTo( this ).getTransform();
       const x = transform.inversePosition2( event.pointer.point ).x;
-      const value = this.valueToPosition.inverse( x );
+      const value = this.valueToPositionProperty.value.inverse( x );
       const valueInRange = options.enabledRangeProperty.value.constrainValue( value );
       const newValue = options.constrainValue( valueInRange );
       valueProperty.set( newValue );
Index: dot/js/Range.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/dot/js/Range.ts b/dot/js/Range.ts
--- a/dot/js/Range.ts   (revision 77a88f07dc3fd814b859a7cf17771071ba76ae81)
+++ b/dot/js/Range.ts   (date 1666158560378)
@@ -92,10 +92,12 @@
   /**
    * Sets the minimum and maximum value of the range
    */
-  public setMinMax( min: number, max: number ): void {
+  public setMinMax( min: number, max: number ): this {
     assert && assert( min <= max, `max must be >= to min. min: ${min}, max: ${max}` );
     this._min = min;
     this._max = max;
+
+    return this;
   }

   /**
@@ -148,6 +150,65 @@
     return ( this._max > range.min ) && ( range.max > this._min );
   }

+  /**
+   * The smallest range that contains both this range and the input range, returned as a copy.
+   *
+   * This is the immutable form of the function includeRange(). This will return a new range, and will not modify
+   * this range.
+   */
+  public union( range: Range ): Range {
+    return new Range( // eslint-disable-line no-html-constructors
+      Math.min( this.min, range.min ),
+      Math.max( this.max, range.max )
+    );
+  }
+
+  /**
+   * The smallest range that is contained by both this range and the input range, returned as a copy.
+   *
+   * This is the immutable form of the function constrainRange(). This will return a new range, and will not modify
+   * this range.
+   */
+  public intersection( range: Range ): Range {
+    return new Range( // eslint-disable-line no-html-constructors
+      Math.max( this.min, range.min ),
+      Math.min( this.max, range.max )
+    );
+  }
+
+  /**
+   * Modifies this range so that it contains both its original range and the input range.
+   *
+   * This is the mutable form of the function union(). This will mutate (change) this range, in addition to returning
+   * this range itself.
+   */
+  public includeRange( range: Range ): Range {
+    return this.setMinMax(
+      Math.min( this.min, range.min ),
+      Math.max( this.max, range.max )
+    );
+  }
+
+  /**
+   * Modifies this range so that it is the largest range contained both in its original range and in the input range.
+   *
+   * This is the mutable form of the function intersection(). This will mutate (change) this range, in addition to returning
+   * this range itself.
+   */
+  public constrainRange( range: Range ): Range {
+    return this.setMinMax(
+      Math.max( this.min, range.min ),
+      Math.min( this.max, range.max )
+    );
+  }
+
+  /**
+   * Returns a new range that is the same as this range, but shifted by the specified amount.
+   */
+  public shifted( n: number ): Range {
+    return new Range( this.min + n, this.max + n ); // eslint-disable-line no-html-constructors
+  }
+
   /**
    * Converts the attributes of this range to a string
    */
jonathanolson commented 1 year ago

Pushed the changes above. I'll probably take a few more steps to complete the dynamic range changes (leaving open and assigned to me).

marlitas commented 1 year ago

I added some review comments with questions/ suggestions. The biggest concern for me right now is the usage of "this" and "other" in the documentation. It's very confusing... I'm not sure if my suggestions are any better but I think finding an appropriate replacement would greatly help with legibility in CompletePieceWiseLinearFunction.

I also wanted to point out that the documentation in the SliderConstraint class was highly effective. I really appreciated the detailed explanation there.

marlitas commented 1 year ago

Forgot to send this back to @jonathanolson in the above comment, so doing so now.

jonathanolson commented 1 year ago

Let's have a call sometime to go over the review here?

pixelzoom commented 1 year ago

I'm seeing problems when I have a Slider inside an HBox, and the HBox is the content for a Panel. The Slider does not appear to be properly resizing. I suspect it's related to dynamic tick labels.

See EvaporationPanel.ts in beers-law-lab, which looks like this on startup:

screenshot_2045

With stringTest=dynamic and 1 press of the RIGHT arrow button, the layout problem is obvious:

screenshot_2046

I'm going to guess that this blocks publication of sims that have sliders with dynamic tick labels.

pixelzoom commented 1 year ago

I tried to reproduce this problem in molecule-polarity ElectronegativityPanel.js, which also has a Slider with dynamic tick labels, but behaves correctly (see below). I have not been able to identify the relevant difference between EvaporationPanel.ts and ElectronegativityPanel.ts.

screenshot_2047 screenshot_2048
marlitas commented 1 year ago

With the scenery helper I was able to confirm that this is in fact affecting all the sliders @pixelzoom provided as examples.

Beer's Law

HSlider minimum width: 179.25 sliderPartsNode width: 198.9 Screenshot 2022-12-12 at 12 59 36 PM

Molecule Polarity

HSlider minimum width: 183 sliderPartsNode width: 189 Screenshot 2022-12-12 at 12 59 21 PM

I refreshed on the piecewise linear function that is powering this logic and I believe I am at the point where I need help/collaboration from @jonathanolson.

pixelzoom commented 1 year ago

I suspect that SliderConstraint (in Slider.ts) is not considering dynamic tick labels. But I don't know enough about layout contraints to understand SliderConstraint. And it's kind of horrifying that it takes 200+ lines to layout a slider.

marlitas commented 1 year ago

@pixelzoom that's exactly what was happening. We weren't updating layout when a tick node would change. That should be fixed.

@pixelzoom can you confirm this is fixed on your end?

pixelzoom commented 1 year ago

Working great in Beer's Law Lab (the problem sim), and still working as expected in other sims like Molecule Polarity.

Closing.

phet-dev commented 12 months ago

Reopening because there is a TODO marked for this issue.

pixelzoom commented 11 months ago

This was automatically reopened because there's a TODO in Range.ts that refers to this issue:

  /**
   * TODO: Allow chaining, https://github.com/phetsims/sun/issues/792
   * Setter for min
   */
  public setMin( min: number ): void {

It looks like @marlitas added the TODO, so assigning to her.

marlitas commented 11 months ago

I'm going to create a new issue for chaining specifically. It seems we may want to just return this in the setters, but I'm not confident that's the exact strategy we want here. https://github.com/phetsims/dot/issues/122