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

[Interactive Graph] Improve Polygon angle snapping is for keyboard users. #2281

Merged
merged 16 commits into from
Mar 6, 2025
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 equation = (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 (equation < -1 || equation > 1) {
equation = Math.round(equation);
}

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

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 @@ -706,7 +709,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 @@ -783,3 +786,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 equation = (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 (equation < -1 || equation > 1) {
equation = Math.round(equation);
}

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

return angle;
}
Expand Down
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per discussion in our previous PR, I am removing these .withMarkings("none") type as it's unnecessary for testing and make it harder to debug for developers.

#2270 (comment)

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 @@ -972,6 +972,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 @@ -1056,7 +1072,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
Loading