phetsims / number-pairs

"Number Pairs" is an educational simulation in HTML5, by PhET Interactive Simulations.
GNU General Public License v3.0
0 stars 0 forks source link

Switch cubes to "free drag" #21

Open marlitas opened 3 weeks ago

marlitas commented 3 weeks ago

Cubes should default to "free dragging". There will be an organize button that groups them in 5's.

marlitas commented 2 weeks ago

Here is a patch where I have some debugging code to indicate when cubes reach an "out of order" state. This patch also changes how cubes are initially placed to match the "free drag" aesthetic and remove cube separator reliance on "snap to positions"

```diff Subject: [PATCH] Update separator logic --- Index: js/common/view/CubesOnWireNode.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/common/view/CubesOnWireNode.ts b/js/common/view/CubesOnWireNode.ts --- a/js/common/view/CubesOnWireNode.ts (revision 14251b4b3a2feb359183e76734922309ee187248) +++ b/js/common/view/CubesOnWireNode.ts (date 1730845762146) @@ -28,6 +28,7 @@ import TReadOnlyProperty from '../../../../axon/js/TReadOnlyProperty.js'; import { Shape } from '../../../../kite/js/imports.js'; import NumberProperty from '../../../../axon/js/NumberProperty.js'; +import dotRandom from '../../../../dot/js/dotRandom.js'; type SelfOptions = { sceneRange: Range; @@ -37,6 +38,8 @@ const LEFT_MOST_CUBE_X = 1; const END_CAP_RADIUS = 10; +const SEPARATOR_RANGE = new Range( 2, 24 ); // empirically determined + type CubesOnWireNodeOptions = StrictOmit & SelfOptions & PickRequired; export default class CubesOnWireNode extends Node { @@ -60,7 +63,7 @@ ) { const modelViewTransform = ModelViewTransform2.createSinglePointScaleMapping( new Vector2( 0, 0 ), - new Vector2( CUBE_WIDTH, 0 ), + new Vector2( CUBE_WIDTH / 2, 0 ), // center of the cube CUBE_WIDTH - CUBE_OVERLAP ); const wire = new Line( 0, 0, countingAreaBounds.width, 0, { @@ -100,6 +103,7 @@ const cubeNode = new CubeNode( countingObject, { + opacity: 0.7, tandem: providedOptions.tandem.createTandem( `cubeNode${i}` ), onDrop: () => { this.cubeDragging = false; @@ -112,15 +116,23 @@ this.cubeModelToNodeMap.set( countingObject, cubeNode ); } ); - // TODO: When we add cubes to the wire they should not snap. They should be placed in random spots Multilink.multilink( [ model.leftAddendProperty, - model.rightAddendProperty, - model.totalProperty, - model.leftAddendCountingObjectsProperty, - model.rightAddendCountingObjectsProperty - ], () => { - !this.cubeDragging && this.snapCubesToPositions(); + model.rightAddendProperty + ], ( leftAddend, rightAddend ) => { + if ( !this.cubeDragging ) { + const cubeSeparatorCenterX = calculateCubeSeparatorPlacement( leftAddend ); + this.cubeSeparatorCenterXProperty.value = this.modelViewTransform.modelToViewX( cubeSeparatorCenterX ); + + const leftAddendPositionRange = new Range( LEFT_MOST_CUBE_X, cubeSeparatorCenterX - 1 ); + this.setRandomPositions( model.leftAddendCountingObjectsProperty.value, leftAddendPositionRange ); + + const rightAddendPositionRange = new Range( + cubeSeparatorCenterX + 1, + this.modelViewTransform.viewToModelDeltaX( countingAreaBounds.width ) - 1 ); + this.setRandomPositions( model.rightAddendCountingObjectsProperty.value, rightAddendPositionRange ); + + } } ); this.cubeModelToNodeMap.forEach( cube => { @@ -128,6 +140,28 @@ } ); } + private setRandomPositions( countingObjects: CountingObject[], positionRange: Range ): void { + const availablePositions = []; + for ( let i = positionRange.min; i < positionRange.max; i++ ) { + availablePositions.push( i ); + } + assert && assert( availablePositions.length >= countingObjects.length, 'There are not enough available positions for counting objects' ); + + const leftAddendPositions: number[] = []; + for ( let i = 0; i < countingObjects.length; i++ ) { + const randomPosition = dotRandom.sample( availablePositions ); + availablePositions.splice( availablePositions.indexOf( randomPosition ), 1 ); + leftAddendPositions.push( randomPosition ); + } + assert && assert( leftAddendPositions.length === countingObjects.length, 'leftAddendPositions.length should match leftAddendCountingObjects length' ); + leftAddendPositions.sort( ( a, b ) => a - b ); + countingObjects.forEach( ( countingObject, i ) => { + const cube = this.cubeModelToNodeMap.get( countingObject )!; + cube.center = new Vector2( this.modelViewTransform.modelToViewX( leftAddendPositions[ i ] ), 0 ); + } ); + + } + /** * Snap the cubes to their positions on the wire based on the addend values. By default, the cubes are arranged in * groups of 5 with a separator between the two addends. @@ -189,7 +223,9 @@ // Calculate the distance the grabbed cube has moved and constrain the movement so that it does not exit // the drag bounds. const oldCenterX = grabbedCube.centerX; - const dragBoundsWithMovingCubes = this.cubeDragBounds.dilatedX( -( CUBE_WIDTH - CUBE_OVERLAP ) * ( cubesToMove.length - 1 ) ); + const minXOffset = draggingRight ? 0 : -( CUBE_WIDTH - CUBE_OVERLAP ) * ( slicedCubes.length - 1 ); + const maxXOffset = draggingRight ? -( CUBE_WIDTH - CUBE_OVERLAP ) * ( slicedCubes.length - 1 ) : 0; + const dragBoundsWithMovingCubes = this.cubeDragBounds.withOffsets( minXOffset, 0, maxXOffset, 0 ); const newCenterX = dragBoundsWithMovingCubes.closestPointTo( grabbedCube.globalToParentPoint( newPosition ) ).x; const deltaX = newCenterX - oldCenterX; grabbedCube.centerX = newCenterX; @@ -203,7 +239,8 @@ if ( !this.rightAddendCountingObjectsProperty.value.includes( cube.model ) ) { // Since a cube is moving to the right, the separator should adjust one position to the left. - this.cubeSeparatorCenterXProperty.value = this.modelViewTransform.modelToViewX( calculateCubeSeparatorPlacement( this.model.leftAddendProperty.value - 1 ) ); + this.cubeSeparatorCenterXProperty.value = this.modelViewTransform.modelToViewX( + calculateCubeSeparatorPlacement( this.model.leftAddendProperty.value - 1 ) ); // Add the cube to the right addend first to avoid duplicate work being done when the left addend value is // updated in the ObservableArray.lengthProperty listener. @@ -211,12 +248,17 @@ this.rightAddendCountingObjectsProperty.value.add( cube.model ); this.leftAddendCountingObjectsProperty.value.remove( cube.model ); cube.model.traverseInactiveObjects = true; + + // Immediately move the cubes past the separator + cube.centerX = Math.max( cube.centerX, this.cubeSeparatorCenterXProperty.value + CUBE_WIDTH - CUBE_OVERLAP ); } } if ( cube.centerX < this.cubeSeparatorCenterXProperty.value ) { if ( !this.leftAddendCountingObjectsProperty.value.includes( cube.model ) ) { + // Since a cube is moving to the left, the separator should adjust one position to the right. - this.cubeSeparatorCenterXProperty.value = this.modelViewTransform.modelToViewX( calculateCubeSeparatorPlacement( this.model.leftAddendProperty.value + 1 ) ); + this.cubeSeparatorCenterXProperty.value = this.modelViewTransform.modelToViewX( + calculateCubeSeparatorPlacement( this.model.leftAddendProperty.value + 1 ) ); // Remove the cube from the right addend first to avoid duplicate work being done when the left addend value is // updated in the ObservableArray.lengthProperty listener. @@ -224,6 +266,9 @@ this.rightAddendCountingObjectsProperty.value.remove( cube.model ); this.leftAddendCountingObjectsProperty.value.add( cube.model ); cube.model.traverseInactiveObjects = true; + + // Immediately move the cubes past the separator + cube.centerX = Math.min( cube.centerX, this.cubeSeparatorCenterXProperty.value - CUBE_WIDTH + CUBE_OVERLAP ); } } } ); @@ -243,14 +288,23 @@ assert && assert( this.leftAddendCountingObjectsProperty.value.length === this.model.leftAddendProperty.value, 'leftAddendObjects.length should match leftAddendNumberProperty' ); assert && assert( this.rightAddendCountingObjectsProperty.value.length === this.model.rightAddendProperty.value, 'rightAddendObjects.length should match rightAddendNumberProperty' ); + + if ( assert ) { + const activeCubes = cubeNodes.filter( cube => cube.model.addendTypeProperty.value !== AddendType.INACTIVE ) + .sort( ( a, b ) => a.centerX - b.centerX ); + let previousIndex = 0; + assert( activeCubes.every( cube => { + const idCorrectSequence = cube.model.id >= previousIndex; + previousIndex = cube.model.id; + return idCorrectSequence; + } ), 'cubes in wrong order' ); + } } } -function calculateCubeSeparatorPlacement( leftAddendValue: number ): number { - - // The cube separator should not be grouped as part of the groups of 5. - const separatorAdjustment = leftAddendValue % 5 === 0 ? 1 : 0; - return Math.floor( leftAddendValue / 5 ) + leftAddendValue - separatorAdjustment + LEFT_MOST_CUBE_X; +function calculateCubeSeparatorPlacement( leftAddend: number ): number { + const separatorMargin = leftAddend / 5 + 1; + return SEPARATOR_RANGE.constrainValue( leftAddend + separatorMargin ); } class WireEndCap extends Path { ```
pixelzoom commented 2 weeks ago

Assigning myself to look at the above patch. @marlitas and I will pair on this tomorrow.

marlitas commented 2 weeks ago

We met with @catherinecarter and decided the following things:

pixelzoom commented 2 weeks ago

A few other notes from this morning's meeting:

marlitas commented 2 weeks ago

I committed what I believe to be a step in the right direction. There are still some questions that pop up that I think would be good to discuss with @catherinecarter and @pixelzoom. Not sure if you want to use design meeting time for that.

You should be able to take a peek on main. There is some overlap stuff that I need to create a more sophisticated algorithm for, but I think it helps give an idea of what things may look like.

I also haven't added the vertical separator yet. I wanted to focus mostly on behavior.

catherinecarter commented 2 weeks ago

It looks really good and in the right direction. I'll add it to the agenda for folks to get a glimpse of the behavior, as well as bring up the possible occlusion to brainstorm solutions. Thanks for spending time on that.

pixelzoom commented 2 weeks ago

I agree -- this seems like the right direction.

I'm also surprised at how much I like the simple rounded rectangles for "beads". This says "abacus" to me loud and clear.

marlitas commented 1 week ago

@pixelzoom is going to take a look at the behavior of the beads. Here are some of the strange behaviors and bugs I am noticing. I am able to achieve these scenarios by quickly moving a large group of beads back and forth. I can replicate with any number of beads on the wire, but it is easiest to achieve with 20.

On the Twenty Screen:

Below are some images of the buggy scenarios.

If you look closely you can see that 17 and 2 are covering up other beads:

image image

The beads should not overlap the wire end caps:

image
pixelzoom commented 1 week ago

When reading BeadsOnWireNode.ts, I found myself getting confused about which things were model elements and which things were view elements, because "bead" was being used for both. So in https://github.com/phetsims/number-pairs/commit/4107b0a49dbe17b48ff3face6801354b1b4bdcc1, I renamed things so that "bead" is used for model elements, "beadNode" is used for view elements -- that is, the typical PhET naming convention. There were no logic changes. I also replace "cube" with "bead" throughout.

pixelzoom commented 1 week ago

@marlitas ... a few more cleanup commits above to improve readability. Let me know if you have questions, or would prefer that I don't make these kinds of changes.

pixelzoom commented 1 week ago

@marlitas I was able to spend ~2.5 hours on this today while traveling. See above commits.

Some things that I discovered, we'll probably need to discuss these.

Beads are getting out of order when dragging quickly because the logic in handleBeadMove does not consider that the dragged bead may "jump over" the x-position of other beads. This entire method seems a little dubious to me, difficult (for me anyway) to understand, and we might consider a different implementation. Hard for me to explain what I had in mind in a comment but... As we drag, note the direction of dragging. Then examine the other beads to the left (lower index) or right (higher index) of the dragged bead, compare adjacent beads need have their x-position changed, and which beads need to move to left/right addends.

Right addend beads were also getting out of order (order was being reversed) when using the "group of 5" button. There seem to be assumptions about the ordering of CountingObjects in leftAddendCountingObjectsProperty and rightAddendCountingObjectsProperty that are not being verified (or documented?) That’s causing objects to be out of order, and organizeInGroupsOfFive to be out of order. I addressed this in https://github.com/phetsims/number-pairs/commit/aff67540bdeb7918ce08265c8c125f79722eaddb, but see the TODOs in NumberPairsModel.

Re drag bounds problems... You shouldn’t have to deal with drag bounds constraints in handleBeadMove. dragBoundsProperty for each BeadNode's drag listener can be derived from model.totalProperty, the bead’s CountingObject.id, and the wire length. You’ll need to decide whether to use also add a transform option value for the drag listener.

Dragging doesn’t know about offset of the pointer from the bead’s position. So if you grab a bead someplace other than the center, it immediately snaps to the pointer’s location. This may be OK, but it’s not what we typically do in PhET sims. See applyOffset options for DragListener.

When drag ends (onDragEnd), need to check if the dragged bead overlaps the separator. If it does, then snap the dragged bead to a new position. Based on which direction it snaps in, also move adjacent beads.

Grabbing a bead and moving it to the left sometime results in adjacent beads moving slightly to the right. I don’t see something similar when moving a bead to the right.

The current use of modelViewTransform is confusing, because it doesn’t involve the wire length.

Bead x-positions are not being restored when switching scenes (totals). I'm guesing you just haven't implemented that yet.

Supporting multitouch (allowing multiple beads to be dragged at the same time) would be horribly complicated and not necessary. I addressed that in https://github.com/phetsims/number-pairs/commit/4bbe0993c7174823d886cbe3169225d7877d7136 - when you grab a bead, and bead that you've interacting with will be interrupted.

Computation of initialBeadXPositions is duplicated in NumberPairsScene and SumModel. See TODO in NumberPairsScene.

marlitas commented 1 week ago

Hard for me to explain what I had in mind in a comment

Yes what you proceeded to explain feels like what I am already doing in handleBeadMove... I think a convo to get on the same page would be helpful. I am also going to look through it and try to improve documentation since I think I did a poor job with that in this function. Perhaps that will help as we converse about it moving forward as well.

There seem to be assumptions about the ordering of CountingObjects in leftAddendCountingObjectsProperty and rightAddendCountingObjectsProperty that are not being verified (or documented?)

I don't know if out of order is inherently a thing to avoid. I think it will be difficult to avoid in the Sum screen no matter what since beads are being added and removed. It was just an easy way for me to quickly identify that something was not behaving as I would have expected while dragging, because nothing in handleBeadMove should change the order.

So... I don't know if it's actually necessary to have an assertion, unless we truly do feel this is a critical standard to ensure that the sim is working properly...

dragBoundsProperty for each BeadNode's drag listener can be derived from model.totalProperty, the bead’s CountingObject.id, and the wire length.

Mmmm that's interesting. I'm not sure if the ID will be enough to ensure we are considering all beads that are in the way of the moving bead's direction... Especially if we can't rely on the bead order (re: Sum screen issue I mentioned above). However, if we refactor to be confident that the bead order is maintained in all use cases this would be good.

When drag ends (onDragEnd), need to check if the dragged bead overlaps the separator. If it does, then snap the dragged bead to a new position. Based on which direction it snaps in, also move adjacent beads.

I have this happening at the end of handleBeadMove so that it is updating overlapping beads as they are jumping over the separator as well... I'm not sure if handling that only when a drag ends is enough. I think it needs to be handled during drag as well.

The current use of modelViewTransform is confusing, because it doesn’t involve the wire length.

yes that's an oversight on my part and good recommendation. I'll apply a fix for that.

Bead x-positions are not being restored when switching scenes (totals). I'm guesing you just haven't implemented that yet.

Correct, I have not.

Supporting multitouch would be horribly complicated and not necessary. I addressed that

I completely agree. Thanks for that code change.

Moving forward here before @pixelzoom and I can discuss more fully in person, I am going to:

The rest should be put on hold until we can discuss in more detail. That combined with other alt-input work should be enough to keep me busy.