Skip to content

Commit 015aace

Browse files
[Interactive Graph] Improve Polygon angle snapping is for keyboard users. (#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
1 parent 5226f43 commit 015aace

File tree

7 files changed

+165
-12
lines changed

7 files changed

+165
-12
lines changed

.changeset/wet-shirts-film.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/perseus": patch
3+
---
4+
5+
Improving the angle snapping behavior for keyboard users in polygon examples.

packages/perseus/src/widgets/interactive-graphs/graphs/components/angle-indicators.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,17 @@ export const PolygonAngle = ({
4545
const b = vec.dist(centerPoint, endPoints[1]);
4646
const c = vec.dist(endPoints[0], endPoints[1]);
4747

48+
let lawOfCosinesRadicand = (a ** 2 + b ** 2 - c ** 2) / (2 * a * b);
49+
50+
// If the equation results in a number greater than 1 or less than -1.
51+
// Correct to ensure a valid angle.
52+
// This ensures we are not producing NaN results from Math.acos.
53+
if (lawOfCosinesRadicand < -1 || lawOfCosinesRadicand > 1) {
54+
lawOfCosinesRadicand = Math.round(lawOfCosinesRadicand);
55+
}
56+
4857
// Law of cosines
49-
const angle = Math.acos((a ** 2 + b ** 2 - c ** 2) / (2 * a * b));
58+
const angle = Math.acos(lawOfCosinesRadicand);
5059

5160
const y1 = centerY + ((startY - centerY) / a) * radius;
5261
const x2 = centerX + ((endX - centerX) / b) * radius;

packages/perseus/src/widgets/interactive-graphs/graphs/polygon.test.tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import {testDependencies} from "../../../../../../testing/test-dependencies";
99
import {MafsGraph} from "../mafs-graph";
1010
import {getBaseMafsGraphPropsForTests} from "../utils";
1111

12-
import {getSideSnapConstraint, hasFocusVisible} from "./polygon";
12+
import {
13+
getAngleSnapConstraint,
14+
getSideSnapConstraint,
15+
hasFocusVisible,
16+
} from "./polygon";
1317

1418
import type {InteractiveGraphState, PolygonGraphState} from "../types";
1519
import type {UserEvent} from "@testing-library/user-event";
@@ -605,3 +609,53 @@ describe("getSideSnapConstraint", () => {
605609
});
606610
});
607611
});
612+
613+
describe("getAngleSnapConstraint", () => {
614+
it("should find the next available coordinate to maintain a whole length sides", () => {
615+
const range: PolygonGraphState["range"] = [
616+
[-10, 10],
617+
[-10, 10],
618+
];
619+
const points: PolygonGraphState["coords"] = [
620+
[0, 0],
621+
[0, 2],
622+
[2, 2],
623+
[2, 0],
624+
];
625+
626+
// We're moving the third point in the top right corner of the polygon (square).
627+
const constraint = getAngleSnapConstraint(points, 2, range);
628+
629+
// The points below represent available angles around the 90 degrees
630+
// angle of the initial top right square (89, 91, etc).
631+
expect(constraint).toEqual({
632+
up: [1.9999999999999998, 2.1048155585660826],
633+
down: [1.9999999999999998, 1.8951844414339165],
634+
left: [1.8951844414339178, 1.9999999999999996],
635+
right: [2.1048155585660826, 1.9999999999999996],
636+
});
637+
});
638+
639+
it("should restrict the available points by the bounds of the graph", () => {
640+
const range: PolygonGraphState["range"] = [
641+
[0, 2.01],
642+
[0, 2.01],
643+
];
644+
const points: PolygonGraphState["coords"] = [
645+
[0, 0],
646+
[0, 2],
647+
[2, 2],
648+
[2, 0],
649+
];
650+
651+
// We're moving the third point in the top right corner of the polygon (square).
652+
const constraint = getAngleSnapConstraint(points, 2, range);
653+
654+
expect(constraint).toEqual({
655+
up: [2, 1.9999999999999996], // direction restricted due to going off the graph
656+
down: [1.9999999999999998, 1.8951844414339165],
657+
left: [1.8951844414339178, 1.9999999999999996],
658+
right: [2, 1.9999999999999996], // direction restricted due to going off the graph
659+
});
660+
});
661+
});

packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ import a11y from "../../../util/a11y";
1111
import {snap} from "../math";
1212
import {isInBound} from "../math/box";
1313
import {actions} from "../reducer/interactive-graph-action";
14-
import {calculateSideSnap} from "../reducer/interactive-graph-reducer";
14+
import {
15+
calculateAngleSnap,
16+
calculateSideSnap,
17+
} from "../reducer/interactive-graph-reducer";
1518
import useGraphConfig from "../reducer/use-graph-config";
16-
import {TARGET_SIZE} from "../utils";
19+
import {bound, TARGET_SIZE} from "../utils";
1720

1821
import {PolygonAngle} from "./components/angle-indicators";
1922
import {MovablePoint} from "./components/movable-point";
@@ -718,7 +721,7 @@ function getKeyboardMovementConstraintForPoint(
718721
case "sides":
719722
return getSideSnapConstraint(points, index, range);
720723
case "angles":
721-
return (p) => p;
724+
return getAngleSnapConstraint(points, index, range);
722725
default:
723726
throw new UnreachableCaseError(snapTo);
724727
}
@@ -795,3 +798,65 @@ export function getSideSnapConstraint(
795798
right: movePointWithConstraint((coord) => vec.add(coord, [1, 0])),
796799
};
797800
}
801+
802+
export function getAngleSnapConstraint(
803+
points: ReadonlyArray<Coord>,
804+
index: number,
805+
range: [Interval, Interval],
806+
): {
807+
up: vec.Vector2;
808+
down: vec.Vector2;
809+
left: vec.Vector2;
810+
right: vec.Vector2;
811+
} {
812+
// Make newPoints mutable.
813+
const newPoints = [...points];
814+
815+
// Get the point that is being moved.
816+
const pointToBeMoved = newPoints[index];
817+
818+
// Create a helper function that moves the point to a valid location
819+
// for angle snapping.
820+
const movePointWithConstraint = (
821+
moveFunc: (coord: vec.Vector2) => vec.Vector2,
822+
): vec.Vector2 => {
823+
// The direction the user is attempting to move the point in.
824+
let destinationAttempt: Coord = bound({
825+
snapStep: [0, 0],
826+
range,
827+
point: moveFunc(pointToBeMoved),
828+
});
829+
// The new point we're moving to.
830+
let newPoint = pointToBeMoved;
831+
832+
// Move the point and keep trying until we are at the boarder
833+
// of the graph.
834+
while (
835+
newPoint[0] === pointToBeMoved[0] &&
836+
newPoint[1] === pointToBeMoved[1] &&
837+
isInBound({range, point: destinationAttempt})
838+
) {
839+
newPoint = calculateAngleSnap(
840+
destinationAttempt,
841+
range,
842+
newPoints,
843+
index,
844+
pointToBeMoved,
845+
);
846+
847+
// Increment the destinationAttempt.
848+
// For every time it does not work increment the direction for x and y.
849+
destinationAttempt = moveFunc(destinationAttempt);
850+
}
851+
return newPoint;
852+
};
853+
854+
// For each direction look for the next movable point by a small step to increase changes
855+
// of finding the next angle value.
856+
return {
857+
up: movePointWithConstraint((coord) => vec.add(coord, [0, 0.1])),
858+
down: movePointWithConstraint((coord) => vec.sub(coord, [0, 0.1])),
859+
left: movePointWithConstraint((coord) => vec.sub(coord, [0.1, 0])),
860+
right: movePointWithConstraint((coord) => vec.add(coord, [0.1, 0])),
861+
};
862+
}

packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,17 @@ export function getAngleFromPoints(points: Coord[], i: number) {
269269
const b = vec.dist(point, pt2);
270270
const c = vec.dist(pt1, pt2);
271271

272+
let lawOfCosinesRadicand = (a ** 2 + b ** 2 - c ** 2) / (2 * a * b);
273+
274+
// If the equation results in a number greater than 1 or less than -1.
275+
// Correct to ensure a valid angle.
276+
// This ensures we are not producing NaN results from Math.acos.
277+
if (lawOfCosinesRadicand < -1 || lawOfCosinesRadicand > 1) {
278+
lawOfCosinesRadicand = Math.round(lawOfCosinesRadicand);
279+
}
280+
272281
// Law of cosines
273-
const angle = Math.acos((a ** 2 + b ** 2 - c ** 2) / (2 * a * b));
282+
const angle = Math.acos(lawOfCosinesRadicand);
274283

275284
return angle;
276285
}

packages/perseus/src/widgets/interactive-graphs/interactive-graph.testdata.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,6 @@ export const polygonWithAnglesQuestion: PerseusRenderer =
217217
.withGridStep(0.5, 0.5)
218218
.withSnapStep(0.25, 0.25)
219219
.withTickStep(0.5, 0.5)
220-
.withMarkings("none")
221220
.withXRange(-1, 6)
222221
.withYRange(-1, 6)
223222
.withPolygon("grid", {
@@ -240,7 +239,6 @@ export const polygonWithAnglesAndAnglesSnapToQuestion: PerseusRenderer =
240239
.withGridStep(0.5, 0.5)
241240
.withSnapStep(0.25, 0.25)
242241
.withTickStep(0.5, 0.5)
243-
.withMarkings("none")
244242
.withXRange(-1, 6)
245243
.withYRange(-1, 6)
246244
.withPolygon("angles", {
@@ -264,7 +262,6 @@ export const polygonWithAnglesAndManySidesQuestion: PerseusRenderer =
264262
.withGridStep(0.5, 0.5)
265263
.withSnapStep(0.25, 0.25)
266264
.withTickStep(0.5, 0.5)
267-
.withMarkings("none")
268265
.withXRange(-1, 6)
269266
.withYRange(-1, 6)
270267
.withPolygon("grid", {
@@ -283,7 +280,6 @@ export const polygonWithAnglesAndFourSidesQuestion: PerseusRenderer =
283280
.withGridStep(0.5, 0.5)
284281
.withSnapStep(0.25, 0.25)
285282
.withTickStep(0.5, 0.5)
286-
.withMarkings("none")
287283
.withXRange(-1, 6)
288284
.withYRange(-1, 6)
289285
.withPolygon("grid", {
@@ -302,7 +298,6 @@ export const polygonWithFourSidesSnappingQuestion: PerseusRenderer =
302298
.withGridStep(0.5, 0.5)
303299
.withSnapStep(0.25, 0.25)
304300
.withTickStep(0.5, 0.5)
305-
.withMarkings("none")
306301
.withXRange(-1, 6)
307302
.withYRange(-1, 6)
308303
.withPolygon("sides", {

packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,22 @@ function boundAndSnapToPolygonAngle(
973973
) {
974974
const startingPoint = coords[index];
975975

976+
return calculateAngleSnap(
977+
destinationPoint,
978+
range,
979+
coords,
980+
index,
981+
startingPoint,
982+
) as vec.Vector2;
983+
}
984+
985+
export function calculateAngleSnap(
986+
destinationPoint: vec.Vector2,
987+
range: [Interval, Interval],
988+
coords: Coord[],
989+
index: number,
990+
startingPoint: vec.Vector2,
991+
) {
976992
// Needed to prevent updating the original coords before the checks for
977993
// degenerate triangles and overlapping sides
978994
const coordsCopy = [...coords];
@@ -1057,7 +1073,7 @@ function boundAndSnapToPolygonAngle(
10571073
// and the angle between the first and second sides of the
10581074
// polygon (angular coordinate) to determine how to adjust the point
10591075
const offset = polar(side, outerAngle + (onLeft ? 1 : -1) * innerAngles[0]);
1060-
return kvector.add(coordsCopy[rel(-1)], offset) as vec.Vector2;
1076+
return kvector.add(coordsCopy[rel(-1)], offset) satisfies vec.Vector2;
10611077
}
10621078

10631079
function boundAndSnapToSides(

0 commit comments

Comments
 (0)