phetsims / axon

Axon provides powerful and concise models for interactive simulations, based on observable Properties and related patterns.
MIT License
11 stars 8 forks source link

Separate BidirectionalDynamicProperty from DynamicProperty #410

Open samreid opened 2 years ago

samreid commented 2 years ago

In https://github.com/phetsims/density-buoyancy-common/issues/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 https://github.com/phetsims/density-buoyancy-common/issues/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?

```diff 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 = TReadOnlyProperty | TReadOnlyProperty; type SelfOptions = { - // 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> extends ReadOnlyProperty { // 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; protected map: ( v: InnerValueType ) => ThisValueType; protected inverseMap: ( t: ThisValueType ) => InnerValueType; - protected bidirectional: boolean; - private valuePropertyProperty: TNullableProperty; + protected valuePropertyProperty: TNullableProperty; private propertyPropertyListener: ( value: InnerValueType, oldValue: InnerValueType | null, innerProperty: TReadOnlyProperty | null ) => void; private propertyListener: ( newPropertyValue: OuterValueType | null, oldPropertyValue: OuterValueType | null | undefined ) => void; @@ -161,7 +140,6 @@ public constructor( valuePropertyProperty: TNullableProperty | TReadOnlyProperty, providedOptions?: DynamicPropertyOptions ) { const options = optionize, SelfOptions, PropertyOptions>()( { - 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 | 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 | 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 ).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> extends DynamicProperty implements Property { + + // @ts-ignore + protected _initialValue: ThisValueType; + + // getInitialValue, initialValue, setInitialValue + + public constructor( valuePropertyProperty: TNullableProperty | TReadOnlyProperty, providedOptions?: DynamicPropertyOptions ) { + 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 | 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 ).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; } /** ```
jonathanolson commented 2 years ago

Seems like a good direction. Would it prevent TProperty with a non-bidirectional DynamicProperty?

samreid commented 1 year ago

This proposal seems reasonable but also like a lot of work. I don't think it is a priority at the moment. Unassigning for now.