Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AnimatedPanZoomListener should generally keep the focused node in view #1558

Closed
jessegreenberg opened this issue May 25, 2023 · 9 comments
Closed

Comments

@jessegreenberg
Copy link
Contributor

Originally reported in phetsims/geometric-optics#471

The AnimatedPanZoomListener should try to keep Nodes that are focused in view when they move with custom key commands. The AnimatedPanZomListener/KeyboardListener needs to do this automatically.

Sometimes, it does this but accidentally for a reason I found in the referenced issue:

So it behaves this way accidentally in BASE because of overlap with the W and S keys. In BASE there are "J+W" and "J+S" hotkeys. The KeyboardDragListener attaches to a Pointer when when one of the WASD keys are pressed. The AnimatedPanZoomListener sees the attached listener for dragging and then pans so that the balloon is at the center of the screen. There is no panning in BASE with "J+C" (jump to center) command because "C" is not a key that the KeyboardDragListener cares about.

@jessegreenberg
Copy link
Contributor Author

I discussed this with @terracoda who confirmed that the sim should pan to the focused item when it moves around with hotkeys. But in addition, we discussed that panning should occur any time the focused Node moves. For example in BASE, we should follow the moving balloon when it is released.

Here is a demo of that much working: https://bayes.colorado.edu/dev/html/jg-tests/balloons-and-static-electricity_en_phet.html

That will impact (and simplify) how this issue is solved because we will keep the focused Node in view always instead of panning in response to specific drag/alt input commands.

@jessegreenberg jessegreenberg changed the title AnimatedPanZoomListener doesn't keep a dragged Node in view when moving with custom key commands AnimatedPanZoomListener should generally keep the focused node in view Sep 8, 2023
@zepumph
Copy link
Member

zepumph commented Sep 12, 2023

From discussion as "Onion. . ." team had it today, this effects Acid Base Solutions and CAV since they both have alt input, so I will mark as blocking from conversation that JG and KP had over in phetsims/center-and-variability#539

@jessegreenberg
Copy link
Contributor Author

This has been tricker than I thought, but I am getting close. Here are my notes and a patch for picking up on Monday.

  1. Using TransformTracker to pan when the focused Node has some transform change along the Trail works great.
  2. Using TransformTracker isn't sufficient when a custom focus highlight is used that is drawn separately from the focused Node (like in CaV).
  3. I tried to change FocusOverlay use AnimatedPanZoomListener pan to keep the actual focus highlight in view. It worked well, except for when there is a complicated focus highlight composed of many Nodes. FocusOverlay then has no way of automatically knowing which Node is intended to be the real focus highlight. (CaV also has this case)
  4. Mouse/Touch Pointers offer a way to request that the AnimatedPanZoomListener pan to keep specific global bounds in view (createPanTargetBounds). We should support that for alternative input as well.

This patch with TODOs is progress toward supporting providing the pan target bounds in KeyboardListener. This is working well for BASE, CaV, and acid-base-solutions so far but needs to be finished and get a bit more thought.

Subject: [PATCH] Fix bug with images and other forms
---
Index: scenery/js/listeners/KeyboardListener.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/scenery/js/listeners/KeyboardListener.ts b/scenery/js/listeners/KeyboardListener.ts
--- a/scenery/js/listeners/KeyboardListener.ts	(revision 2b58df06678677d27fd6d9421a92c972d5e3cb69)
+++ b/scenery/js/listeners/KeyboardListener.ts	(date 1694817778555)
@@ -45,6 +45,7 @@
 import optionize from '../../../phet-core/js/optionize.js';
 import { EnglishStringToCodeMap, FocusManager, globalKeyStateTracker, scenery, SceneryEvent, TInputListener } from '../imports.js';
 import KeyboardUtils from '../accessibility/KeyboardUtils.js';
+import Bounds2 from '../../../dot/js/Bounds2.js';
 
 // NOTE: The typing for ModifierKey and OneKeyStroke is limited TypeScript, there is a limitation to the number of
 //       entries in a union type. If that limitation is not acceptable remove this typing. OR maybe TypeScript will
@@ -207,6 +208,13 @@
     this._fireOnHoldDelay = options.fireOnHoldDelay;
     this._fireOnHoldInterval = options.fireOnHoldInterval;
 
+    this._createPanTargetBounds = null;
+    this._pointerListener = {
+
+      // TODO: Implementation is required for an attached listener, what shoudl this be?
+      interrupt: () => {}
+    };
+
     this._activeKeyGroups = [];
 
     this.keysDown = false;
@@ -246,6 +254,17 @@
           if ( this.areKeysDownForListener( keyGroup ) &&
                keyGroup.keys.includes( globalKeyStateTracker.getLastKeyDown()! ) ) {
 
+            // Only on the first keygroup activation should this listener be added, and we cannot attach to
+            // an already-attached pointer
+
+            // TODO: Best place to remove this listener? keyup when there are no more active groups and
+            // the pointerListener is attached?
+            if ( this._activeKeyGroups.length === 0 && !event.pointer.isAttached() ) {
+              if ( this._createPanTargetBounds ) {
+                event.pointer.addInputListener( this._pointerListener, true );
+              }
+            }
+
             this._activeKeyGroups.push( keyGroup );
 
             this.keysDown = true;
@@ -387,6 +406,14 @@
     this._abort && event.abort();
   }
 
+  public setCreatePanTargetBounds( createGlobalPanBounds: ( () => Bounds2 ) | null ): void {
+    this._createPanTargetBounds = createGlobalPanBounds;
+    this._pointerListener.createPanTargetBounds = createGlobalPanBounds;
+
+    // TODO: If set to null, detach the pointer listener from the pointer. That means this listener
+    //   will need to keep a reference to the pointer.
+  }
+
   /**
    * This is part of the scenery Input API (implementing TInputListener). Handle the keydown event when not
    * added to the global key events. Target will be the Node, Display, or Pointer this listener was added to.
Index: scenery/js/overlays/HighlightOverlay.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/scenery/js/overlays/HighlightOverlay.ts b/scenery/js/overlays/HighlightOverlay.ts
--- a/scenery/js/overlays/HighlightOverlay.ts	(revision 2b58df06678677d27fd6d9421a92c972d5e3cb69)
+++ b/scenery/js/overlays/HighlightOverlay.ts	(date 1694813560039)
@@ -12,7 +12,7 @@
 import BooleanProperty from '../../../axon/js/BooleanProperty.js';
 import { Shape } from '../../../kite/js/imports.js';
 import optionize from '../../../phet-core/js/optionize.js';
-import { ActivatedReadingBlockHighlight, Display, Focus, FocusManager, HighlightFromNode, HighlightPath, Node, scenery, TOverlay, TPaint, Trail, TransformTracker } from '../imports.js';
+import { ActivatedReadingBlockHighlight, animatedPanZoomSingleton, Display, Focus, FocusManager, HighlightFromNode, HighlightPath, Node, scenery, TOverlay, TPaint, Trail, TransformTracker } from '../imports.js';
 import { InteractiveHighlightingNode } from '../accessibility/voicing/InteractiveHighlighting.js';
 import { ReadingBlockNode } from '../accessibility/voicing/ReadingBlock.js';
 import TProperty from '../../../axon/js/TProperty.js';
@@ -179,6 +179,8 @@
     this.focusRootNode.addChild( this.highlightNode );
     this.focusRootNode.addChild( this.readingBlockHighlightNode );
 
+    window.highlightNode = this.highlightNode;
+
     this.pdomFocusHighlightsVisibleProperty = options.pdomFocusHighlightsVisibleProperty;
     this.interactiveHighlightsVisibleProperty = options.interactiveHighlightsVisibleProperty;
     this.readingBlockHighlightsVisibleProperty = options.readingBlockHighlightsVisibleProperty;
@@ -377,6 +379,21 @@
 
     // handle any changes to the focus highlight while the node has focus
     node.focusHighlightChangedEmitter.addListener( this.focusHighlightListener );
+    //
+    // // Want to keep the global bounds of the highlight node in view
+    //
+    //
+    //
+    // // Add a listener to the transform tracker that will pan to keep the focus highlight in view
+    //
+    //
+    //
+    // window.myListener = () => {
+    //   animatedPanZoomSingleton.listener.keepBoundsInView( this.highlightNode.globalBounds, false );
+    // };
+    // if ( animatedPanZoomSingleton.initialized ) {
+    //   this.transformTracker!.addListener( window.myListener );
+    // }
   }
 
   /**
Index: soccer-common/js/view/SoccerBallNode.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/soccer-common/js/view/SoccerBallNode.ts b/soccer-common/js/view/SoccerBallNode.ts
--- a/soccer-common/js/view/SoccerBallNode.ts	(revision 029a4154a7621a9da818652a43dfceef7c0b5600)
+++ b/soccer-common/js/view/SoccerBallNode.ts	(date 1694816428409)
@@ -120,13 +120,15 @@
     // have enough space to move that far. If we make sure that bounds surrounding the SoccerObjectNode have a width
     // of 2 model units the pointer will always have enough space to drag the SoccerObjectNode to a new position.
     // See https://github.com/phetsims/center-and-variability/issues/88
-    dragListener.createPanTargetBounds = () => {
+    this.createPanTargetBounds = () => {
       const modelPosition = soccerBall.positionProperty.value;
       const modelBounds = new Bounds2( modelPosition.x - 1, modelPosition.y - 1, modelPosition.x + 1, modelPosition.y + 1 );
       const viewBounds = modelViewTransform.modelToViewBounds( modelBounds );
       return this.parentToGlobalBounds( viewBounds );
     };
 
+    dragListener.createPanTargetBounds = this.createPanTargetBounds;
+
     this.addInputListener( dragListener );
 
     // For PhET-iO, allow clients to shut off interactivity via this Property.
@@ -159,7 +161,7 @@
     } );
 
     soccerBall.resetEmitter.addListener( () => {
-      this.focusable = false;
+      // this.focusable = false;
       this.pickable = false;
       this.mouseArea = Shape.rectangle( 0, 0, 0, 0 );
       this.touchArea = Shape.rectangle( 0, 0, 0, 0 );
@@ -168,7 +170,7 @@
     this.addLinkedElement( soccerBall );
 
     // Not focusable until the ball has been kicked into the play area
-    this.focusable = false;
+    // this.focusable = false;
 
     super.addDebugText( soccerBall );
   }
Index: scenery/js/listeners/AnimatedPanZoomListener.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/scenery/js/listeners/AnimatedPanZoomListener.ts b/scenery/js/listeners/AnimatedPanZoomListener.ts
--- a/scenery/js/listeners/AnimatedPanZoomListener.ts	(revision 2b58df06678677d27fd6d9421a92c972d5e3cb69)
+++ b/scenery/js/listeners/AnimatedPanZoomListener.ts	(date 1694818155625)
@@ -16,7 +16,7 @@
 import EventType from '../../../tandem/js/EventType.js';
 import isSettingPhetioStateProperty from '../../../tandem/js/isSettingPhetioStateProperty.js';
 import PhetioAction from '../../../tandem/js/PhetioAction.js';
-import { EventIO, Focus, FocusManager, globalKeyStateTracker, Intent, KeyboardDragListener, KeyboardUtils, KeyboardZoomUtils, KeyStateTracker, Mouse, MultiListenerPress, Node, PanZoomListener, PanZoomListenerOptions, PDOMPointer, PDOMUtils, Pointer, PressListener, scenery, SceneryEvent, Trail } from '../imports.js';
+import { EventIO, Focus, FocusManager, globalKeyStateTracker, Intent, KeyboardDragListener, KeyboardUtils, KeyboardZoomUtils, KeyStateTracker, Mouse, MultiListenerPress, Node, PanZoomListener, PanZoomListenerOptions, PDOMPointer, PDOMUtils, Pointer, PressListener, scenery, SceneryEvent, Trail, TransformTracker } from '../imports.js';
 import optionize, { EmptySelfOptions } from '../../../phet-core/js/optionize.js';
 import Tandem from '../../../tandem/js/Tandem.js';
 
@@ -102,6 +102,8 @@
   // to scale the target Node
   private trackpadGestureStartScale = 1;
 
+  private _transformTracker: TransformTracker | null = null;
+
   private readonly disposeAnimatedPanZoomListener: () => void;
 
   /**
@@ -192,7 +194,39 @@
     globalKeyStateTracker.keydownEmitter.addListener( this.windowKeydown.bind( this ) );
 
     const displayFocusListener = ( focus: Focus | null ) => {
-      if ( focus && this.getCurrentScale() > 1 ) {
+      if ( this._transformTracker ) {
+        this._transformTracker.dispose();
+        this._transformTracker = null;
+      }
+
+      if ( focus ) {
+        let trailToTrack = focus.trail;
+        if ( focus.trail.containsNode( this._targetNode ) ) {
+
+          // Track transforms to the focused Node, but exclude the targetNode so that repositions during pan don't
+          // trigger another transform update.
+          const indexOfTarget = focus.trail.nodes.indexOf( this._targetNode );
+          const indexOfLeaf = focus.trail.nodes.length; // end of slice is not included
+          trailToTrack = focus.trail.slice( indexOfTarget, indexOfLeaf );
+        }
+
+        this._transformTracker = new TransformTracker( trailToTrack );
+        this._transformTracker.addListener( () => {
+          if ( this.getCurrentScale() > 1 ) {
+
+            // globalBounds needs full trail to the focused Node (not the subtrail used by TransformTracker)
+
+            // TODO: We need a way to get the panTargetBounds from the PDOMPointer here. If we listen to a
+            //   global focus event that could be possible. Or we could look for the PDOMPointer through the
+            //   focus.display._input!
+            const globalBounds = focus.trail.localToGlobalBounds( focus.trail.lastNode().localBounds );
+            // const globalBounds = window.highlightNode.globalBounds;
+            console.log( globalBounds );
+            this.keepBoundsInView( globalBounds, true );
+          }
+        } );
+
+        // Pan to the focus trail right away if it is off-screen
         this.keepTrailInView( focus.trail );
       }
     };
@@ -221,6 +255,10 @@
       // @ts-expect-error - Event type for this Safari specific event isn't available yet
       boundGestureChangeListener && window.removeEventListener( 'gestureChange', boundGestureChangeListener );
 
+      if ( this._transformTracker ) {
+        this._transformTracker.dispose();
+      }
+
       FocusManager.pdomFocusProperty.unlink( displayFocusListener );
     };
   }
Index: soccer-common/js/view/SoccerSceneView.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/soccer-common/js/view/SoccerSceneView.ts b/soccer-common/js/view/SoccerSceneView.ts
--- a/soccer-common/js/view/SoccerSceneView.ts	(revision 029a4154a7621a9da818652a43dfceef7c0b5600)
+++ b/soccer-common/js/view/SoccerSceneView.ts	(date 1694816817007)
@@ -288,6 +288,20 @@
       }
     } );
 
+    focusedSoccerBallProperty.link( focusedBall => {
+      if ( focusedBall ) {
+        keyboardListener.setCreatePanTargetBounds( () => {
+          const modelPosition = focusedBall.positionProperty.value;
+          const modelBounds = new Bounds2( modelPosition.x - 1, modelPosition.y - 1, modelPosition.x + 1, modelPosition.y + 1 );
+          const viewBounds = modelViewTransform.modelToViewBounds( modelBounds );
+          return backLayerSoccerBallLayer.parentToGlobalBounds( viewBounds );
+        } );
+      }
+      else {
+        keyboardListener.setCreatePanTargetBounds( null );
+      }
+    } );
+
     // Set the outer group focus region to cover the entire area where soccer balls may land, translate lower so it also includes the number line and labels
     this.focusHighlightPath = new HighlightPath( null, {
       outerStroke: HighlightPath.OUTER_LIGHT_GROUP_FOCUS_COLOR,

@jessegreenberg
Copy link
Contributor Author

jessegreenberg commented Sep 18, 2023

Coming back to this, I don't think that will fully work either. In the case that a Node has focus and the transform changes in a scripted way there is no input and no Pointer to have globalTargetBounds.

I think what we must do is add a field to ParallelDOM, which will be a function to calculate the "pan target bounds". The function will likely use a trail and require there is only one instance of a Node. But that is acceptable because focusable Nodes already cannot have more than one Instance.

I will see if I can do a memory profile of the impact of adding another field to ParallelDOM before going too far into it.

@kathy-phet
Copy link

@jessegreenberg - Feel free to reach out to @jonathanolson for collaborative discussion as well.

@jessegreenberg
Copy link
Contributor Author

New patch to switch machines (not commitable):

Subject: [PATCH] Fix bug with images and other forms
---
Index: scenery/js/overlays/HighlightOverlay.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/scenery/js/overlays/HighlightOverlay.ts b/scenery/js/overlays/HighlightOverlay.ts
--- a/scenery/js/overlays/HighlightOverlay.ts	(revision 2b58df06678677d27fd6d9421a92c972d5e3cb69)
+++ b/scenery/js/overlays/HighlightOverlay.ts	(date 1694813560039)
@@ -12,7 +12,7 @@
 import BooleanProperty from '../../../axon/js/BooleanProperty.js';
 import { Shape } from '../../../kite/js/imports.js';
 import optionize from '../../../phet-core/js/optionize.js';
-import { ActivatedReadingBlockHighlight, Display, Focus, FocusManager, HighlightFromNode, HighlightPath, Node, scenery, TOverlay, TPaint, Trail, TransformTracker } from '../imports.js';
+import { ActivatedReadingBlockHighlight, animatedPanZoomSingleton, Display, Focus, FocusManager, HighlightFromNode, HighlightPath, Node, scenery, TOverlay, TPaint, Trail, TransformTracker } from '../imports.js';
 import { InteractiveHighlightingNode } from '../accessibility/voicing/InteractiveHighlighting.js';
 import { ReadingBlockNode } from '../accessibility/voicing/ReadingBlock.js';
 import TProperty from '../../../axon/js/TProperty.js';
@@ -179,6 +179,8 @@
     this.focusRootNode.addChild( this.highlightNode );
     this.focusRootNode.addChild( this.readingBlockHighlightNode );
 
+    window.highlightNode = this.highlightNode;
+
     this.pdomFocusHighlightsVisibleProperty = options.pdomFocusHighlightsVisibleProperty;
     this.interactiveHighlightsVisibleProperty = options.interactiveHighlightsVisibleProperty;
     this.readingBlockHighlightsVisibleProperty = options.readingBlockHighlightsVisibleProperty;
@@ -377,6 +379,21 @@
 
     // handle any changes to the focus highlight while the node has focus
     node.focusHighlightChangedEmitter.addListener( this.focusHighlightListener );
+    //
+    // // Want to keep the global bounds of the highlight node in view
+    //
+    //
+    //
+    // // Add a listener to the transform tracker that will pan to keep the focus highlight in view
+    //
+    //
+    //
+    // window.myListener = () => {
+    //   animatedPanZoomSingleton.listener.keepBoundsInView( this.highlightNode.globalBounds, false );
+    // };
+    // if ( animatedPanZoomSingleton.initialized ) {
+    //   this.transformTracker!.addListener( window.myListener );
+    // }
   }
 
   /**
Index: soccer-common/js/view/SoccerBallNode.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/soccer-common/js/view/SoccerBallNode.ts b/soccer-common/js/view/SoccerBallNode.ts
--- a/soccer-common/js/view/SoccerBallNode.ts	(revision 029a4154a7621a9da818652a43dfceef7c0b5600)
+++ b/soccer-common/js/view/SoccerBallNode.ts	(date 1695062446693)
@@ -120,12 +120,13 @@
     // have enough space to move that far. If we make sure that bounds surrounding the SoccerObjectNode have a width
     // of 2 model units the pointer will always have enough space to drag the SoccerObjectNode to a new position.
     // See https://github.com/phetsims/center-and-variability/issues/88
-    dragListener.createPanTargetBounds = () => {
+    this.createFocusPanTargetBounds = () => {
       const modelPosition = soccerBall.positionProperty.value;
       const modelBounds = new Bounds2( modelPosition.x - 1, modelPosition.y - 1, modelPosition.x + 1, modelPosition.y + 1 );
       const viewBounds = modelViewTransform.modelToViewBounds( modelBounds );
       return this.parentToGlobalBounds( viewBounds );
     };
+    dragListener.createPanTargetBounds = this.createFocusPanTargetBounds;
 
     this.addInputListener( dragListener );
 
@@ -159,7 +160,7 @@
     } );
 
     soccerBall.resetEmitter.addListener( () => {
-      this.focusable = false;
+      // this.focusable = false;
       this.pickable = false;
       this.mouseArea = Shape.rectangle( 0, 0, 0, 0 );
       this.touchArea = Shape.rectangle( 0, 0, 0, 0 );
@@ -168,7 +169,7 @@
     this.addLinkedElement( soccerBall );
 
     // Not focusable until the ball has been kicked into the play area
-    this.focusable = false;
+    // this.focusable = false;
 
     super.addDebugText( soccerBall );
   }
Index: scenery/js/accessibility/pdom/ParallelDOM.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/scenery/js/accessibility/pdom/ParallelDOM.ts b/scenery/js/accessibility/pdom/ParallelDOM.ts
--- a/scenery/js/accessibility/pdom/ParallelDOM.ts	(revision 2b58df06678677d27fd6d9421a92c972d5e3cb69)
+++ b/scenery/js/accessibility/pdom/ParallelDOM.ts	(date 1695058681313)
@@ -142,6 +142,7 @@
 import TinyForwardingProperty from '../../../../axon/js/TinyForwardingProperty.js';
 import TProperty from '../../../../axon/js/TProperty.js';
 import isSettingPhetioStateProperty from '../../../../tandem/js/isSettingPhetioStateProperty.js';
+import Bounds2 from '../../../../dot/js/Bounds2.js';
 
 const INPUT_TAG = PDOMUtils.TAGS.INPUT;
 const P_TAG = PDOMUtils.TAGS.P;
@@ -229,6 +230,8 @@
   'ariaDescribedbyAssociations',
   'activeDescendantAssociations',
 
+  'createPanTargetBounds',
+
   'positionInPDOM',
 
   'pdomTransformSourceNode'
@@ -283,6 +286,9 @@
   ariaDescribedbyAssociations?: Association[]; // sets the list of aria-describedby associations between from this node to others (including itself)
   activeDescendantAssociations?: Association[]; // sets the list of aria-activedescendant associations between from this node to others (including itself)
 
+  // Sets the function that creates the pan target bounds for this node
+  createFocusPanTargetBounds?: ( () => Bounds2 ) | null;
+
   positionInPDOM?: boolean; // Sets whether the node's DOM elements are positioned in the viewport
 
   pdomTransformSourceNode?: Node | null; // { sets the node that controls primary sibling element positioning in the display, see setPDOMTransformSourceNode()
@@ -503,6 +509,10 @@
   // pdomTransformSourceNode cannot use DAG.
   private _pdomTransformSourceNode: Node | null;
 
+  // If this is provided, the AnimatedPanZoomListener will attempt to keep this Node in view as long as it has
+  // focus
+  private _createFocusPanTargetBounds: ( () => Bounds2 ) | null;
+
   // Contains information about what pdom displays
   // this node is "visible" for, see PDOMDisplaysInfo.js for more information.
   // (scenery-internal)
@@ -593,6 +603,7 @@
     this._pdomOrder = null;
     this._pdomParent = null;
     this._pdomTransformSourceNode = null;
+    this._createFocusPanTargetBounds = null;
     this._pdomDisplaysInfo = new PDOMDisplaysInfo( this as unknown as Node );
     this._pdomInstances = [];
     this._positionInPDOM = false;
@@ -2563,6 +2574,23 @@
     return this._pdomTransformSourceNode;
   }
 
+  public setCreateFocusPanTargetBounds( createFocusPanTargetBounds: null | ( () => Bounds2 ) ): void {
+    this._createFocusPanTargetBounds = createFocusPanTargetBounds;
+  }
+
+
+  public getCreateFocusPanTargetBounds(): null | ( () => Bounds2 ) {
+    return this._createFocusPanTargetBounds;
+  }
+
+  public set createFocusPanTargetBounds( createFocusPanTargetBounds: null | ( () => Bounds2 ) ) {
+    this.setCreateFocusPanTargetBounds( createFocusPanTargetBounds );
+  }
+
+  public get createFocusPanTargetBounds(): null | ( () => Bounds2 ) {
+    return this.getCreateFocusPanTargetBounds();
+  }
+
   /**
    * Sets whether the PDOM sibling elements are positioned in the correct place in the viewport. Doing so is a
    * requirement for custom gestures on touch based screen readers. However, doing this DOM layout is expensive so
Index: scenery/js/listeners/AnimatedPanZoomListener.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/scenery/js/listeners/AnimatedPanZoomListener.ts b/scenery/js/listeners/AnimatedPanZoomListener.ts
--- a/scenery/js/listeners/AnimatedPanZoomListener.ts	(revision 2b58df06678677d27fd6d9421a92c972d5e3cb69)
+++ b/scenery/js/listeners/AnimatedPanZoomListener.ts	(date 1695064177328)
@@ -16,7 +16,7 @@
 import EventType from '../../../tandem/js/EventType.js';
 import isSettingPhetioStateProperty from '../../../tandem/js/isSettingPhetioStateProperty.js';
 import PhetioAction from '../../../tandem/js/PhetioAction.js';
-import { EventIO, Focus, FocusManager, globalKeyStateTracker, Intent, KeyboardDragListener, KeyboardUtils, KeyboardZoomUtils, KeyStateTracker, Mouse, MultiListenerPress, Node, PanZoomListener, PanZoomListenerOptions, PDOMPointer, PDOMUtils, Pointer, PressListener, scenery, SceneryEvent, Trail } from '../imports.js';
+import { EventIO, Focus, FocusManager, globalKeyStateTracker, Intent, KeyboardDragListener, KeyboardUtils, KeyboardZoomUtils, KeyStateTracker, Mouse, MultiListenerPress, Node, PanZoomListener, PanZoomListenerOptions, PDOMPointer, PDOMUtils, Pointer, PressListener, scenery, SceneryEvent, Trail, TransformTracker } from '../imports.js';
 import optionize, { EmptySelfOptions } from '../../../phet-core/js/optionize.js';
 import Tandem from '../../../tandem/js/Tandem.js';
 
@@ -102,6 +102,8 @@
   // to scale the target Node
   private trackpadGestureStartScale = 1;
 
+  private _transformTracker: TransformTracker | null = null;
+
   private readonly disposeAnimatedPanZoomListener: () => void;
 
   /**
@@ -192,7 +194,51 @@
     globalKeyStateTracker.keydownEmitter.addListener( this.windowKeydown.bind( this ) );
 
     const displayFocusListener = ( focus: Focus | null ) => {
-      if ( focus && this.getCurrentScale() > 1 ) {
+      if ( this._transformTracker ) {
+        this._transformTracker.dispose();
+        this._transformTracker = null;
+      }
+
+      if ( focus ) {
+        const lastNode = focus.trail.lastNode();
+
+        let trailToTrack = focus.trail;
+        if ( focus.trail.containsNode( this._targetNode ) ) {
+
+          // Track transforms to the focused Node, but exclude the targetNode so that repositions during pan don't
+          // trigger another transform update.
+          const indexOfTarget = focus.trail.nodes.indexOf( this._targetNode );
+          const indexOfLeaf = focus.trail.nodes.length; // end of slice is not included
+          trailToTrack = focus.trail.slice( indexOfTarget, indexOfLeaf );
+        }
+
+        this._transformTracker = new TransformTracker( trailToTrack );
+        this._transformTracker.addListener( () => {
+          if ( this.getCurrentScale() > 1 ) {
+
+            // globalBounds needs full trail to the focused Node (not the subtrail used by TransformTracker)
+
+            // TODO: We need a way to get the panTargetBounds from the PDOMPointer here. If we listen to a
+            //   global focus event that could be possible. Or we could look for the PDOMPointer through the
+            //   focus.display._input!
+            let globalBounds: Bounds2;
+            if ( lastNode.createFocusPanTargetBounds ) {
+
+              // This Node has a custom bounds area that we need to keep in view
+              globalBounds = lastNode.createFocusPanTargetBounds();
+            }
+            else {
+
+              // by default, use the global bounds of the Node
+              globalBounds = focus.trail.localToGlobalBounds( focus.trail.lastNode().localBounds );
+            }
+
+            // const globalBounds = window.highlightNode.globalBounds;
+            this.keepBoundsInView( globalBounds, true );
+          }
+        } );
+
+        // Pan to the focus trail right away if it is off-screen
         this.keepTrailInView( focus.trail );
       }
     };
@@ -221,6 +267,10 @@
       // @ts-expect-error - Event type for this Safari specific event isn't available yet
       boundGestureChangeListener && window.removeEventListener( 'gestureChange', boundGestureChangeListener );
 
+      if ( this._transformTracker ) {
+        this._transformTracker.dispose();
+      }
+
       FocusManager.pdomFocusProperty.unlink( displayFocusListener );
     };
   }
@@ -518,19 +568,19 @@
         sceneryLog && sceneryLog.InputListener && sceneryLog.pop();
       }
     }
-
-    if ( KeyboardUtils.isMovementKey( domEvent ) ) {
-      if ( keyboardDragIntent ) {
-
-        // Look for any attached pointers if we are dragging with a keyboard and add them to the list. When dragging
-        // stops the Pointer listener is detached and the pointer is removed from the list in `step()`.
-        if ( event.pointer.isAttached() ) {
-          if ( !this._attachedPointers.includes( event.pointer ) ) {
-            this._attachedPointers.push( event.pointer );
-          }
-        }
-      }
-    }
+    //
+    // if ( KeyboardUtils.isMovementKey( domEvent ) ) {
+    //   if ( keyboardDragIntent ) {
+    //
+    //     // Look for any attached pointers if we are dragging with a keyboard and add them to the list. When dragging
+    //     // stops the Pointer listener is detached and the pointer is removed from the list in `step()`.
+    //     if ( event.pointer.isAttached() ) {
+    //       if ( !this._attachedPointers.includes( event.pointer ) ) {
+    //         this._attachedPointers.push( event.pointer );
+    //       }
+    //     }
+    //   }
+    // }
   }
 
   /**
@@ -820,9 +870,9 @@
    * Pan to a provided Node, attempting to place the node in the center of the transformedPanBounds. It may not end
    * up exactly in the center since we have to make sure panBounds are completely filled with targetNode content.
    */
-  public panToNode( node: Node ): void {
+  public panToNode( node: Node, panToCenter = true ): void {
     assert && assert( this._panBounds.isFinite(), 'panBounds should be defined when panning.' );
-    this.keepBoundsInView( node.globalBounds, true );
+    this.keepBoundsInView( node.globalBounds, panToCenter );
   }
 
   /**
Index: balloons-and-static-electricity/js/balloons-and-static-electricity/view/BalloonNode.js
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/balloons-and-static-electricity/js/balloons-and-static-electricity/view/BalloonNode.js b/balloons-and-static-electricity/js/balloons-and-static-electricity/view/BalloonNode.js
--- a/balloons-and-static-electricity/js/balloons-and-static-electricity/view/BalloonNode.js	(revision 3eee435a586db7dc8c94615ba1fbc13c3defeec4)
+++ b/balloons-and-static-electricity/js/balloons-and-static-electricity/view/BalloonNode.js	(date 1695058793310)
@@ -405,6 +405,10 @@
       tandem: tandem.createTandem( 'grabDragInteraction' )
     } );
 
+    grabDragTargetNode.createFocusPanTargetBounds = () => {
+      return grabDragTargetNode.globalBounds.shiftedY( -900 );
+    };
+
     // jump to the wall on 'J + W'
     this.keyboardDragListener.hotkeys = [
       {
Index: soccer-common/js/view/SoccerSceneView.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/soccer-common/js/view/SoccerSceneView.ts b/soccer-common/js/view/SoccerSceneView.ts
--- a/soccer-common/js/view/SoccerSceneView.ts	(revision 029a4154a7621a9da818652a43dfceef7c0b5600)
+++ b/soccer-common/js/view/SoccerSceneView.ts	(date 1695064332064)
@@ -7,7 +7,7 @@
  * @author Sam Reid (PhET Interactive Simulations)
  */
 
-import { HighlightFromNode, HighlightPath, InteractiveHighlightingNode, KeyboardListener, Node } from '../../../scenery/js/imports.js';
+import { animatedPanZoomSingleton, HighlightFromNode, HighlightPath, InteractiveHighlightingNode, KeyboardListener, Node } from '../../../scenery/js/imports.js';
 import SoccerBallNode from './SoccerBallNode.js';
 import { SoccerBallPhase } from '../model/SoccerBallPhase.js';
 import SoccerSceneModel from '../model/SoccerSceneModel.js';
@@ -173,6 +173,10 @@
 
         }
         hasKeyboardFocusProperty.value = true;
+
+        if ( focusedSoccerBallProperty.value !== null ) {
+          animatedPanZoomSingleton.listener.panToNode( soccerBallMap.get( focusedSoccerBallProperty.value )!, true );
+        }
       },
       blur: () => {
         isSoccerBallKeyboardGrabbedProperty.value = false;
@@ -252,6 +256,8 @@
               soccerBall.valueProperty.value = physicalRange.constrainValue( soccerBall.valueProperty.value! + delta );
               soccerBall.toneEmitter.emit( soccerBall.valueProperty.value );
             }
+
+            animatedPanZoomSingleton.listener.panToNode( soccerBallMap.get( focusedSoccerBallProperty.value )!, false );
           }
           else if ( keysPressed === 'enter' || keysPressed === 'space' ) {
             isSoccerBallKeyboardGrabbedProperty.value = !isSoccerBallKeyboardGrabbedProperty.value;
@@ -288,6 +294,12 @@
       }
     } );
 
+    // focusedSoccerBallProperty.link( focusedBall => {
+    //   if ( focusedBall ) {
+    //     animatedPanZoomSingleton.listener.panToNode( soccerBallMap.get( focusedBall )! );
+    //   }
+    // } );
+
     // Set the outer group focus region to cover the entire area where soccer balls may land, translate lower so it also includes the number line and labels
     this.focusHighlightPath = new HighlightPath( null, {
       outerStroke: HighlightPath.OUTER_LIGHT_GROUP_FOCUS_COLOR,

@jessegreenberg
Copy link
Contributor Author

jessegreenberg commented Sep 19, 2023

OK the change using TransformTracker was committed. I decided not to support cases like CaV. I that instance a totally custom focus management implementation (not using scenery's focus) was used and so it doesn't seem beneficial to add new functionality just to support it. Over there, controlling the pan directly with animatedPanZoomSingleton worked well. I did add a new setter function to ParallelDOM.ts that lets you customize the focusPanTargetBounds. I did this memory test in built acid-base-solutions to convince myself that the change was in the noise for memory impact:

image

I reached out to @jonathanolson for a quick synchronous review which we will do soon.

@jessegreenberg
Copy link
Contributor Author

jessegreenberg commented Sep 20, 2023

@jonathanolson and I met to review the changes and he recommended the following improvements:

  1. The bounds set by the client should be in the local coordinate frame. That matches things like clipArea and is most convenient.
  2. setCreateFocusPanTargetBounds doesn't allow for observing changes to those bounds. So instead of that function, we will add a new field to ParallelDOM like focusPanTargetBoundsProperty: null | TReadOnlyProperty<Bounds2> so that we can update the pan in the listener when those bounds change. With a default of null, we avoid the memory hit for almost all Nodes and you can tap into this feature when you want to. For now, we won't support mutating this Property, you have to set it at creation.

jessegreenberg added a commit that referenced this issue Oct 12, 2023
… observable bounds changes for the animatedPanZoomSingleton, see #1558
@jessegreenberg
Copy link
Contributor Author

The above commit has this working. This change adds the new Property and replaces setCreateFocusPanTargetBounds, and has the bounds in the Node's local coordinate frame. Ready to close.

I tested in BASE by setting a set of bounds on the balloon that were shifted during animation and observed the pan/zoom listener move around the sim as I expected. I used a combination of alt input and mouse input. I ran local aqua and unit tests. Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants