Skip to content

Commit

Permalink
[Interactive Graph] Improve Polygon angle snapping is for keyboard us…
Browse files Browse the repository at this point in the history
…ers. (#2281)

## Summary:
Updating Polygon interactive graph implementation to have a better keyboard accessible angle snapping. This is done by hooking up the angle snapping behavior into a constraint function to make the keyboard arrow keys more effective in locating the next best point and angle combination.

Issue: LEMS-2893

## Test plan:
- Go to: /?path=/story/perseuseditor-widgets-interactive-graph--interactive-graph-polygon
- Set **Snap to:** to **angle measures**
- Use your keyboard (tab and arrow keys) to move the various polygon points.
- Notice the movement is smaller to have a better chance of getting the angles correct!

Author: catandthemachines

Reviewers: catandthemachines, nishasy, benchristel

Required Reviewers:

Approved By: nishasy

Checks: ✅ 8 checks were successful

Pull Request URL: #2281
  • Loading branch information
catandthemachines authored Mar 6, 2025
1 parent 5226f43 commit 015aace
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .changeset/wet-shirts-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": patch
---

Improving the angle snapping behavior for keyboard users in polygon examples.
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,17 @@ export const PolygonAngle = ({
const b = vec.dist(centerPoint, endPoints[1]);
const c = vec.dist(endPoints[0], endPoints[1]);

let lawOfCosinesRadicand = (a ** 2 + b ** 2 - c ** 2) / (2 * a * b);

// If the equation results in a number greater than 1 or less than -1.
// Correct to ensure a valid angle.
// This ensures we are not producing NaN results from Math.acos.
if (lawOfCosinesRadicand < -1 || lawOfCosinesRadicand > 1) {
lawOfCosinesRadicand = Math.round(lawOfCosinesRadicand);
}

// Law of cosines
const angle = Math.acos((a ** 2 + b ** 2 - c ** 2) / (2 * a * b));
const angle = Math.acos(lawOfCosinesRadicand);

const y1 = centerY + ((startY - centerY) / a) * radius;
const x2 = centerX + ((endX - centerX) / b) * radius;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {testDependencies} from "../../../../../../testing/test-dependencies";
import {MafsGraph} from "../mafs-graph";
import {getBaseMafsGraphPropsForTests} from "../utils";

import {getSideSnapConstraint, hasFocusVisible} from "./polygon";
import {
getAngleSnapConstraint,
getSideSnapConstraint,
hasFocusVisible,
} from "./polygon";

import type {InteractiveGraphState, PolygonGraphState} from "../types";
import type {UserEvent} from "@testing-library/user-event";
Expand Down Expand Up @@ -605,3 +609,53 @@ describe("getSideSnapConstraint", () => {
});
});
});

describe("getAngleSnapConstraint", () => {
it("should find the next available coordinate to maintain a whole length sides", () => {
const range: PolygonGraphState["range"] = [
[-10, 10],
[-10, 10],
];
const points: PolygonGraphState["coords"] = [
[0, 0],
[0, 2],
[2, 2],
[2, 0],
];

// We're moving the third point in the top right corner of the polygon (square).
const constraint = getAngleSnapConstraint(points, 2, range);

// The points below represent available angles around the 90 degrees
// angle of the initial top right square (89, 91, etc).
expect(constraint).toEqual({
up: [1.9999999999999998, 2.1048155585660826],
down: [1.9999999999999998, 1.8951844414339165],
left: [1.8951844414339178, 1.9999999999999996],
right: [2.1048155585660826, 1.9999999999999996],
});
});

it("should restrict the available points by the bounds of the graph", () => {
const range: PolygonGraphState["range"] = [
[0, 2.01],
[0, 2.01],
];
const points: PolygonGraphState["coords"] = [
[0, 0],
[0, 2],
[2, 2],
[2, 0],
];

// We're moving the third point in the top right corner of the polygon (square).
const constraint = getAngleSnapConstraint(points, 2, range);

expect(constraint).toEqual({
up: [2, 1.9999999999999996], // direction restricted due to going off the graph
down: [1.9999999999999998, 1.8951844414339165],
left: [1.8951844414339178, 1.9999999999999996],
right: [2, 1.9999999999999996], // direction restricted due to going off the graph
});
});
});
71 changes: 68 additions & 3 deletions packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import a11y from "../../../util/a11y";
import {snap} from "../math";
import {isInBound} from "../math/box";
import {actions} from "../reducer/interactive-graph-action";
import {calculateSideSnap} from "../reducer/interactive-graph-reducer";
import {
calculateAngleSnap,
calculateSideSnap,
} from "../reducer/interactive-graph-reducer";
import useGraphConfig from "../reducer/use-graph-config";
import {TARGET_SIZE} from "../utils";
import {bound, TARGET_SIZE} from "../utils";

import {PolygonAngle} from "./components/angle-indicators";
import {MovablePoint} from "./components/movable-point";
Expand Down Expand Up @@ -718,7 +721,7 @@ function getKeyboardMovementConstraintForPoint(
case "sides":
return getSideSnapConstraint(points, index, range);
case "angles":
return (p) => p;
return getAngleSnapConstraint(points, index, range);
default:
throw new UnreachableCaseError(snapTo);
}
Expand Down Expand Up @@ -795,3 +798,65 @@ export function getSideSnapConstraint(
right: movePointWithConstraint((coord) => vec.add(coord, [1, 0])),
};
}

export function getAngleSnapConstraint(
points: ReadonlyArray<Coord>,
index: number,
range: [Interval, Interval],
): {
up: vec.Vector2;
down: vec.Vector2;
left: vec.Vector2;
right: vec.Vector2;
} {
// Make newPoints mutable.
const newPoints = [...points];

// Get the point that is being moved.
const pointToBeMoved = newPoints[index];

// Create a helper function that moves the point to a valid location
// for angle snapping.
const movePointWithConstraint = (
moveFunc: (coord: vec.Vector2) => vec.Vector2,
): vec.Vector2 => {
// The direction the user is attempting to move the point in.
let destinationAttempt: Coord = bound({
snapStep: [0, 0],
range,
point: moveFunc(pointToBeMoved),
});
// The new point we're moving to.
let newPoint = pointToBeMoved;

// Move the point and keep trying until we are at the boarder
// of the graph.
while (
newPoint[0] === pointToBeMoved[0] &&
newPoint[1] === pointToBeMoved[1] &&
isInBound({range, point: destinationAttempt})
) {
newPoint = calculateAngleSnap(
destinationAttempt,
range,
newPoints,
index,
pointToBeMoved,
);

// Increment the destinationAttempt.
// For every time it does not work increment the direction for x and y.
destinationAttempt = moveFunc(destinationAttempt);
}
return newPoint;
};

// For each direction look for the next movable point by a small step to increase changes
// of finding the next angle value.
return {
up: movePointWithConstraint((coord) => vec.add(coord, [0, 0.1])),
down: movePointWithConstraint((coord) => vec.sub(coord, [0, 0.1])),
left: movePointWithConstraint((coord) => vec.sub(coord, [0.1, 0])),
right: movePointWithConstraint((coord) => vec.add(coord, [0.1, 0])),
};
}
11 changes: 10 additions & 1 deletion packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,17 @@ export function getAngleFromPoints(points: Coord[], i: number) {
const b = vec.dist(point, pt2);
const c = vec.dist(pt1, pt2);

let lawOfCosinesRadicand = (a ** 2 + b ** 2 - c ** 2) / (2 * a * b);

// If the equation results in a number greater than 1 or less than -1.
// Correct to ensure a valid angle.
// This ensures we are not producing NaN results from Math.acos.
if (lawOfCosinesRadicand < -1 || lawOfCosinesRadicand > 1) {
lawOfCosinesRadicand = Math.round(lawOfCosinesRadicand);
}

// Law of cosines
const angle = Math.acos((a ** 2 + b ** 2 - c ** 2) / (2 * a * b));
const angle = Math.acos(lawOfCosinesRadicand);

return angle;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,6 @@ export const polygonWithAnglesQuestion: PerseusRenderer =
.withGridStep(0.5, 0.5)
.withSnapStep(0.25, 0.25)
.withTickStep(0.5, 0.5)
.withMarkings("none")
.withXRange(-1, 6)
.withYRange(-1, 6)
.withPolygon("grid", {
Expand All @@ -240,7 +239,6 @@ export const polygonWithAnglesAndAnglesSnapToQuestion: PerseusRenderer =
.withGridStep(0.5, 0.5)
.withSnapStep(0.25, 0.25)
.withTickStep(0.5, 0.5)
.withMarkings("none")
.withXRange(-1, 6)
.withYRange(-1, 6)
.withPolygon("angles", {
Expand All @@ -264,7 +262,6 @@ export const polygonWithAnglesAndManySidesQuestion: PerseusRenderer =
.withGridStep(0.5, 0.5)
.withSnapStep(0.25, 0.25)
.withTickStep(0.5, 0.5)
.withMarkings("none")
.withXRange(-1, 6)
.withYRange(-1, 6)
.withPolygon("grid", {
Expand All @@ -283,7 +280,6 @@ export const polygonWithAnglesAndFourSidesQuestion: PerseusRenderer =
.withGridStep(0.5, 0.5)
.withSnapStep(0.25, 0.25)
.withTickStep(0.5, 0.5)
.withMarkings("none")
.withXRange(-1, 6)
.withYRange(-1, 6)
.withPolygon("grid", {
Expand All @@ -302,7 +298,6 @@ export const polygonWithFourSidesSnappingQuestion: PerseusRenderer =
.withGridStep(0.5, 0.5)
.withSnapStep(0.25, 0.25)
.withTickStep(0.5, 0.5)
.withMarkings("none")
.withXRange(-1, 6)
.withYRange(-1, 6)
.withPolygon("sides", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,22 @@ function boundAndSnapToPolygonAngle(
) {
const startingPoint = coords[index];

return calculateAngleSnap(
destinationPoint,
range,
coords,
index,
startingPoint,
) as vec.Vector2;
}

export function calculateAngleSnap(
destinationPoint: vec.Vector2,
range: [Interval, Interval],
coords: Coord[],
index: number,
startingPoint: vec.Vector2,
) {
// Needed to prevent updating the original coords before the checks for
// degenerate triangles and overlapping sides
const coordsCopy = [...coords];
Expand Down Expand Up @@ -1057,7 +1073,7 @@ function boundAndSnapToPolygonAngle(
// and the angle between the first and second sides of the
// polygon (angular coordinate) to determine how to adjust the point
const offset = polar(side, outerAngle + (onLeft ? 1 : -1) * innerAngles[0]);
return kvector.add(coordsCopy[rel(-1)], offset) as vec.Vector2;
return kvector.add(coordsCopy[rel(-1)], offset) satisfies vec.Vector2;
}

function boundAndSnapToSides(
Expand Down

0 comments on commit 015aace

Please sign in to comment.