Skip to content

Separate BidirectionalDynamicProperty from DynamicProperty #410

Description

@samreid

In phetsims/density-buoyancy-common#74, I asked:

should we change bidirectional from an option to a subtype?

@jonathanolson replied:

That sounds possible (they should be bidirectional or not on creation), however unless we use mixins (yuk), I don't see how that would fix the inheritance hierarchy.

@marlitas and I sketched out what BidirectionalDynamicProperty would look like, and it didn't exactly solve the problem from phetsims/density-buoyancy-common#74 but we wanted to put a patch here in case we want to pursue BidirectionalDynamicProperty anyways. We saw around 38 cases of bidirectional: true that would be converted.

@jonathanolson do you like this direction?

Details
Index: js/DynamicProperty.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/js/DynamicProperty.ts b/js/DynamicProperty.ts
--- a/js/DynamicProperty.ts	(revision a5961582e9fed7de7a0df002257961872cec4d7c)
+++ b/js/DynamicProperty.ts	(date 1661357767234)
@@ -60,23 +60,6 @@
  *  } );
  * So that if the currentSceneProperty's value is null, the value of our DynamicProperty will be Color.BLACK.
  *
- *******************************
- * 'bidirectional' option
- *******************************
- * If you would like for direct changes to this Property to change the original source (bidirectional synchronization),
- * then pass bidirectional:true:
- *   const firstProperty = new Property( 5 );
- *   const secondProperty = new Property( 10 );
- *   const numberPropertyProperty = new Property( firstProperty );
- *   const dynamicProperty = new DynamicProperty( numberPropertyProperty, { bidirectional: true } );
- *   dynamicProperty.value = 2; // allowed now that it is bidirectional, otherwise prohibited
- *   firstProperty.value; // 2
- *   numberPropertyProperty.value = secondProperty; // change which Property is active
- *   dynamicProperty.value; // 10, from the new Property
- *   dynamicProperty.value = 0;
- *   secondProperty.value; // 0, set above.
- *   firstProperty.value; // still 2 from above, since our dynamic Property switched to the other Property
- *
  *******************************
  * 'map' and 'inverseMap' options
  *******************************
@@ -107,9 +90,6 @@
 export type TNullableProperty<T> = TReadOnlyProperty<T | null> | TReadOnlyProperty<T>;
 
 type SelfOptions<ThisValueType, InnerValueType, OuterValueType> = {
-  // If set to true then changes to this Property (if valuePropertyProperty.value is non-null at the time) will also be
-  // made to derive( valuePropertyProperty.value ).
-  bidirectional?: boolean;
 
   // If valuePropertyProperty.value === null, this dynamicProperty will act instead like
   // derive( valuePropertyProperty.value ) === new Property( defaultValue ). Note that if a custom map function is
@@ -143,14 +123,13 @@
 export default class DynamicProperty<ThisValueType, InnerValueType = ThisValueType, OuterValueType = Property<InnerValueType>> extends ReadOnlyProperty<ThisValueType> {
 
   // Set to true when this Property's value is changing from an external source.
-  private isExternallyChanging: boolean;
+  protected isExternallyChanging: boolean;
 
   private defaultValue: InnerValueType;
   protected derive: ( u: OuterValueType ) => TProperty<InnerValueType>;
   protected map: ( v: InnerValueType ) => ThisValueType;
   protected inverseMap: ( t: ThisValueType ) => InnerValueType;
-  protected bidirectional: boolean;
-  private valuePropertyProperty: TNullableProperty<OuterValueType>;
+  protected valuePropertyProperty: TNullableProperty<OuterValueType>;
   private propertyPropertyListener: ( value: InnerValueType, oldValue: InnerValueType | null, innerProperty: TReadOnlyProperty<InnerValueType> | null ) => void;
   private propertyListener: ( newPropertyValue: OuterValueType | null, oldPropertyValue: OuterValueType | null | undefined ) => void;
 
@@ -161,7 +140,6 @@
   public constructor( valuePropertyProperty: TNullableProperty<OuterValueType> | TReadOnlyProperty<OuterValueType>, providedOptions?: DynamicPropertyOptions<ThisValueType, InnerValueType, OuterValueType> ) {
 
     const options = optionize<DynamicPropertyOptions<ThisValueType, InnerValueType, OuterValueType>, SelfOptions<ThisValueType, InnerValueType, OuterValueType>, PropertyOptions<ThisValueType>>()( {
-      bidirectional: false,
       defaultValue: null as unknown as InnerValueType,
       derive: _.identity,
       map: _.identity,
@@ -187,7 +165,6 @@
     this.derive = derive;
     this.map = map;
     this.inverseMap = inverseMap;
-    this.bidirectional = options.bidirectional;
     this.valuePropertyProperty = valuePropertyProperty;
     this.isExternallyChanging = false;
 
@@ -196,12 +173,6 @@
 
     // Rehook our listener to whatever is the active Property.
     valuePropertyProperty.link( this.propertyListener );
-
-    // If we aren't bidirectional, we should never add this listener.
-    if ( options.bidirectional ) {
-      // No unlink needed, since our own disposal will remove this listener.
-      this.lazyLink( this.onSelfChange.bind( this ) );
-    }
   }
 
   /**
@@ -212,20 +183,7 @@
    * @param oldValue - Ignored for our purposes, but is the 2nd parameter for Property listeners.
    * @param innerProperty
    */
-  private onPropertyPropertyChange( value: InnerValueType, oldValue: InnerValueType | null, innerProperty: TReadOnlyProperty<InnerValueType> | null ): void {
-
-    // If the value of the inner Property is already the inverse of our value, we will never attempt to update our
-    // own value in an attempt to limit "ping-ponging" cases mainly due to numerical error. Otherwise it would be
-    // possible, given certain values and map/inverse, for both Properties to toggle back-and-forth.
-    // See https://github.com/phetsims/axon/issues/197 for more details.
-    if ( this.bidirectional && this.valuePropertyProperty.value !== null && innerProperty ) {
-      const currentProperty = this.derive( this.valuePropertyProperty.value );
-      // Notably, we only want to cancel interactions if the Property that sent the notification is still the Property
-      // we are paying attention to.
-      if ( currentProperty === innerProperty && innerProperty.areValuesEqual( this.inverseMap( this.value ), innerProperty.get() ) ) {
-        return;
-      }
-    }
+  protected onPropertyPropertyChange( value: InnerValueType, oldValue: InnerValueType | null, innerProperty: TReadOnlyProperty<InnerValueType> | null ): void {
 
     // Since we override the setter here, we need to call the version on the prototype
     super.set( this.map( value ) );
@@ -252,25 +210,6 @@
     }
   }
 
-  /**
-   * Listener added to ourself when we are bidirectional
-   */
-  private onSelfChange( value: ThisValueType ): void {
-    assert && assert( this.bidirectional );
-
-    if ( this.valuePropertyProperty.value !== null ) {
-      const innerProperty = this.derive( this.valuePropertyProperty.value );
-
-      // If our new value is the result of map() from the inner Property's value, we don't want to propagate that
-      // change back to the innerProperty in the case where the map/inverseMap are not exact matches (generally due
-      // to floating-point issues).
-      // See https://github.com/phetsims/axon/issues/197 for more details.
-      if ( !this.areValuesEqual( value, this.map( innerProperty.value ) ) ) {
-        innerProperty.value = this.inverseMap( value );
-      }
-    }
-  }
-
   /**
    * Disposes this Property
    */
@@ -284,31 +223,6 @@
     super.dispose();
   }
 
-  /**
-   * Resets the current property (if it's a Property instead of a TinyProperty)
-   */
-  public reset(): void {
-    assert && assert( this.bidirectional, 'Cannot reset a non-bidirectional DynamicProperty' );
-
-    if ( this.valuePropertyProperty.value !== null ) {
-      const property = this.derive( this.valuePropertyProperty.value );
-      ( property as Property<InnerValueType> ).reset();
-    }
-  }
-
-  /**
-   * Prevent setting this Property manually if it is not marked as bidirectional.
-   */
-  public override set( value: ThisValueType ): void {
-    assert && assert( this.bidirectional,
-      `Cannot set values directly to a non-bidirectional DynamicProperty, tried to set: ${value}` );
-
-    this.isExternallyChanging = true;
-    super.set( value );
-
-    this.isExternallyChanging = false;
-  }
-
   /**
    * Overridden to make public
    */
@@ -324,13 +238,6 @@
   public override set value( value: ThisValueType ) {
     this.set( value );
   }
-
-  /**
-   * Returns true if this Property value can be set externally, by set() or .value =
-   */
-  public override isSettable(): boolean {
-    return super.isSettable() && this.bidirectional;
-  }
 }
 
 axon.register( 'DynamicProperty', DynamicProperty );
Index: js/BidirectionalDynamicProperty.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/js/BidirectionalDynamicProperty.ts b/js/BidirectionalDynamicProperty.ts
new file mode 100644
--- /dev/null	(date 1661358105803)
+++ b/js/BidirectionalDynamicProperty.ts	(date 1661358105803)
@@ -0,0 +1,153 @@
+// Copyright 2017-2022, University of Colorado Boulder
+
+/**
+ *******************************
+ * 'bidirectional' features
+ *******************************
+ * If you would like for direct changes to this Property to change the original source (bidirectional synchronization),
+ * then pass bidirectional:true:
+ *   const firstProperty = new Property( 5 );
+ *   const secondProperty = new Property( 10 );
+ *   const numberPropertyProperty = new Property( firstProperty );
+ *   const dynamicProperty = new BidirectionalDynamicProperty( numberPropertyProperty, { bidirectional: true } );
+ *   dynamicProperty.value = 2; // allowed now that it is bidirectional, otherwise prohibited
+ *   firstProperty.value; // 2
+ *   numberPropertyProperty.value = secondProperty; // change which Property is active
+ *   dynamicProperty.value; // 10, from the new Property
+ *   dynamicProperty.value = 0;
+ *   secondProperty.value; // 0, set above.
+ *   firstProperty.value; // still 2 from above, since our dynamic Property switched to the other Property
+ *
+ * @author Sam Reid (PhET Interactive Simulations)
+ * @author Marla Schulz (PhET Interactive Simulations)
+ */
+
+import axon from './axon.js';
+import Property from './Property.js';
+import TReadOnlyProperty from './TReadOnlyProperty.js';
+import DynamicProperty, { DynamicPropertyOptions, TNullableProperty } from './DynamicProperty.js';
+
+// @ts-ignore
+export default class BidirectionalDynamicProperty<ThisValueType, InnerValueType = ThisValueType, OuterValueType = Property<InnerValueType>> extends DynamicProperty<ThisValueType, InnerValueType, OuterValueType> implements Property<ThisValueType> {
+
+  // @ts-ignore
+  protected _initialValue: ThisValueType;
+
+  // getInitialValue, initialValue, setInitialValue
+
+  public constructor( valuePropertyProperty: TNullableProperty<OuterValueType> | TReadOnlyProperty<OuterValueType>, providedOptions?: DynamicPropertyOptions<ThisValueType, InnerValueType, OuterValueType> ) {
+    super( valuePropertyProperty, providedOptions );
+    // If we aren't bidirectional, we should never add this listener.
+    // No unlink needed, since our own disposal will remove this listener.
+    this.lazyLink( this.onSelfChange.bind( this ) );
+  }
+
+  /**
+   * Listener added to the active inner Property.
+   *
+   * @param value - Should be either our defaultValue (if valuePropertyProperty.value is null), or
+   *                derive( valuePropertyProperty.value ).value otherwise.
+   * @param oldValue - Ignored for our purposes, but is the 2nd parameter for Property listeners.
+   * @param innerProperty
+   */
+  protected override onPropertyPropertyChange( value: InnerValueType, oldValue: InnerValueType | null, innerProperty: TReadOnlyProperty<InnerValueType> | null ): void {
+
+    // If the value of the inner Property is already the inverse of our value, we will never attempt to update our
+    // own value in an attempt to limit "ping-ponging" cases mainly due to numerical error. Otherwise it would be
+    // possible, given certain values and map/inverse, for both Properties to toggle back-and-forth.
+    // See https://github.com/phetsims/axon/issues/197 for more details.
+    if ( this.valuePropertyProperty.value !== null && innerProperty ) {
+      const currentProperty = this.derive( this.valuePropertyProperty.value );
+      // Notably, we only want to cancel interactions if the Property that sent the notification is still the Property
+      // we are paying attention to.
+      if ( currentProperty === innerProperty && innerProperty.areValuesEqual( this.inverseMap( this.value ), innerProperty.get() ) ) {
+        return;
+      }
+    }
+
+    // Since we override the setter here, we need to call the version on the prototype
+    super.onPropertyPropertyChange( value, oldValue, innerProperty );
+  }
+
+  /**
+   * Listener added to ourself when we are bidirectional
+   */
+  private onSelfChange( value: ThisValueType ): void {
+
+    if ( this.valuePropertyProperty.value !== null ) {
+      const innerProperty = this.derive( this.valuePropertyProperty.value );
+
+      // If our new value is the result of map() from the inner Property's value, we don't want to propagate that
+      // change back to the innerProperty in the case where the map/inverseMap are not exact matches (generally due
+      // to floating-point issues).
+      // See https://github.com/phetsims/axon/issues/197 for more details.
+      if ( !this.areValuesEqual( value, this.map( innerProperty.value ) ) ) {
+        innerProperty.value = this.inverseMap( value );
+      }
+    }
+  }
+
+  /**
+   * Resets the current property (if it's a Property instead of a TinyProperty)
+   */
+  public reset(): void {
+    if ( this.valuePropertyProperty.value !== null ) {
+      const property = this.derive( this.valuePropertyProperty.value );
+      ( property as Property<InnerValueType> ).reset();
+    }
+  }
+
+  /**
+   * Prevent setting this Property manually if it is not marked as bidirectional.
+   */
+  public override set( value: ThisValueType ): void {
+    this.isExternallyChanging = true;
+    super.set( value );
+
+    this.isExternallyChanging = false;
+  }
+
+  /**
+   * Overridden to make public
+   */
+  public override get value(): ThisValueType {
+    return super.value;
+  }
+
+  /**
+   * Overridden to make public
+   * We ran performance tests on Chrome, and determined that calling super.value = newValue is statistically significantly
+   * slower at the p = 0.10 level( looping over 10,000 value calls). Therefore, we prefer this optimization.
+   */
+  public override set value( value: ThisValueType ) {
+    this.set( value );
+  }
+
+  /**
+   * Returns true if this Property value can be set externally, by set() or .value =
+   */
+  public override isSettable(): boolean {
+    return true;
+  }
+
+  /**
+   * Returns the initial value of this Property.
+   */
+  public getInitialValue(): ThisValueType {
+    return this._initialValue;
+  }
+
+  public get initialValue(): ThisValueType {
+    return this.getInitialValue();
+  }
+
+  /**
+   * Stores the specified value as the initial value, which will be taken on reset. Sims should use this sparingly,
+   * typically only in situations where the initial value is unknowable at instantiation.
+   */
+  public setInitialValue( initialValue: ThisValueType ): void {
+    this._initialValue = initialValue;
+  }
+}
+
+axon.register( 'BidirectionalDynamicProperty', BidirectionalDynamicProperty );
\ No newline at end of file
Index: js/ReadOnlyProperty.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/js/ReadOnlyProperty.ts b/js/ReadOnlyProperty.ts
--- a/js/ReadOnlyProperty.ts	(revision a5961582e9fed7de7a0df002257961872cec4d7c)
+++ b/js/ReadOnlyProperty.ts	(date 1661356616216)
@@ -199,7 +199,7 @@
    * Returns true if the value can be set externally, using .value= or set()
    */
   public isSettable(): boolean {
-    return true;
+    return false;
   }
 
   /**

</details.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions