Skip to content

Commit

Permalink
Add keyboard controls to angle graphs (#1472)
Browse files Browse the repository at this point in the history
This PR makes it so angle graph questions can be solved with only a keyboard -
no mouse required!

There are several tricky constraints to this problem:

- Every distinct state of the graph needs to be reachable using only the
  keyboard.
- In order for the movement to feel natural, pressing an arrow key should
  always move the point in the corresponding direction: up, down, left, or right.
- However, the points need to snap, not to a grid, but to angle measures
  (in degrees).
- The side points of the angle should not be allowed to get too close to the
  vertex - if they overlap the vertex there's no way to drag them away again.
- The points shouldn't be able to move outside the graph bounds
- It should never be possible for an edge point to get "stuck" or blocked in its
  movement by the edge of the graph, or by the angle vertex.

I believe this PR solves for all these constraints. To make it work, I had to do
a few slightly weird things:

- I constrained the movement of the vertex so no part of the angle-measure arc
  can go outside the graph bounds. This ensures that all the control points of
  the angle can always be placed onscreen, and prevents the side points from
  getting stuck on the edges of the graph.
- I reworked the interface of `useDraggable`, renaming `constrain` to
  `constrainKeyboardMovement`. Movement constraints for dragging are now
  implemented entirely in the reducer. (They were almost all in the reducer
  before; circles were the only graph type that still depended on the constrain
  function for dragging.)
- I also introduced a new type of `useDraggable` constraint, where the destination
  points for the up, left, right, and down arrows are listed explicitly. I think
  this ultimately makes the code clearer. It's also more efficient (since
  useDraggable does not have to do a bunch of repeated trig calculations to
  search for valid destination points).

Issue: https://khanacademy.atlassian.net/browse/LEMS-2134

## Test plan:

View `/?path=/story/perseus-widgets-interactive-graph--angle-with-mafs` in
Storybook. Try to break the graph.

Author: benchristel

Reviewers: benchristel, SonicScrewdriver, mark-fitzgerald, Myranae, nicolecomputer, nishasy

Required Reviewers:

Approved By: SonicScrewdriver

Checks: ✅ codecov/project, ✅ codecov/patch, ✅ Upload Coverage (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Jest Coverage (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ gerald

Pull Request URL: #1472
  • Loading branch information
benchristel authored Aug 9, 2024
1 parent b8a342c commit 4c2ace5
Show file tree
Hide file tree
Showing 21 changed files with 505 additions and 129 deletions.
5 changes: 5 additions & 0 deletions .changeset/neat-days-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": minor
---

Add keyboard controls to Mafs angle graphs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const enableMafs: APIOptions = {
mafs: {
segment: true,
polygon: true,
angle: true,
},
},
};
Expand Down Expand Up @@ -155,6 +156,10 @@ export const Sinusoid = (args: StoryArgs): React.ReactElement => (
<RendererWithDebugUI question={sinusoidQuestion} />
);

export const AngleWithMafs = (args: StoryArgs): React.ReactElement => (
<RendererWithDebugUI apiOptions={enableMafs} question={angleQuestion} />
);

// TODO(jeremy): As of Jan 2022 there are no peresus items in production that
// use the "quadratic" graph type.
// "quadratic"
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {getAngleSideConstraint} from "./angle";

import type {vec} from "mafs";

function closeTo(x: number) {
return expect.closeTo(x, 6);
}

describe("getAngleSideConstraint", () => {
it("prevents vertical movement given a vertical side of an angle", () => {
const side: vec.Vector2 = [0, 5];
const vertex: vec.Vector2 = [0, 0];
const snapDegrees = 45;

const constraint = getAngleSideConstraint(side, vertex, snapDegrees);

expect(constraint).toEqual({
up: side,
down: side,
left: [closeTo(-5), 5],
right: [closeTo(5), 5],
});
});

it("prevents horizontal movement given a horizontal side of an angle", () => {
const side: vec.Vector2 = [5, 0];
const vertex: vec.Vector2 = [0, 0];
const snapDegrees = 45;

const constraint = getAngleSideConstraint(side, vertex, snapDegrees);

expect(constraint).toEqual({
up: [5, closeTo(5)],
down: [5, closeTo(-5)],
left: side,
right: side,
});
});

it("assigns the correct points to 'left' and 'right' when the side is pointing down", () => {
const side: vec.Vector2 = [0, -5];
const vertex: vec.Vector2 = [0, 0];
const snapDegrees = 45;

const constraint = getAngleSideConstraint(side, vertex, snapDegrees);

expect(constraint).toEqual({
up: side,
down: side,
left: [closeTo(-5), -5],
right: [closeTo(5), -5],
});
});

it("assigns the correct points to 'up' and 'down' when the side is pointing left", () => {
const side: vec.Vector2 = [-5, 0];
const vertex: vec.Vector2 = [0, 0];
const snapDegrees = 45;

const constraint = getAngleSideConstraint(side, vertex, snapDegrees);

expect(constraint).toEqual({
up: [-5, closeTo(5)],
down: [-5, closeTo(-5)],
left: side,
right: side,
});
});
});
124 changes: 103 additions & 21 deletions packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,31 @@
import {vec} from "mafs";
import * as React from "react";

import {calculateAngleInDegrees, polar} from "../math";
import {findIntersectionOfRays} from "../math/geometry";
import {actions} from "../reducer/interactive-graph-action";
import useGraphConfig from "../reducer/use-graph-config";

import {Angle} from "./components/angle-indicators";
import {trimRange} from "./components/movable-line";
import {StyledMovablePoint} from "./components/movable-point";
import {MovablePoint} from "./components/movable-point";
import {SVGLine} from "./components/svg-line";
import {Vector} from "./components/vector";
import {useTransformVectorsToPixels} from "./use-transform";
import {getIntersectionOfRayWithBox} from "./utils";

import type {CollinearTuple} from "../../../perseus-types";
import type {Segment} from "../math/geometry";
import type {AngleGraphState, MafsGraphProps} from "../types";
import type {vec} from "mafs";

type AngleGraphProps = MafsGraphProps<AngleGraphState>;

export function AngleGraph(props: AngleGraphProps) {
const {dispatch, graphState} = props;
const {graphDimensionsInPixels} = useGraphConfig();

const {
coords,
showAngles,
range,
allowReflexAngles,
angleOffsetDeg,
snapDegrees,
} = graphState;
const {coords, showAngles, range, allowReflexAngles, snapDegrees} =
graphState;

// Break the coords into the two end points and the center point
const endPoints: [vec.Vector2, vec.Vector2] = [coords[0], coords[2]];
Expand Down Expand Up @@ -78,7 +75,6 @@ export function AngleGraph(props: AngleGraphProps) {
vertex: centerPoint,
coords: endPoints,
allowReflexAngles: allowReflexAngles || false, // Whether to allow reflex angles or not
angleOffsetDeg: angleOffsetDeg || 0, // The angle offset from the x-axis
snapDegrees: snapDegrees || 1, // The multiple of degrees to snap to
range: range,
showAngles: showAngles || false, // Whether to show the angle or not
Expand All @@ -89,16 +85,102 @@ export function AngleGraph(props: AngleGraphProps) {
<>
{svgLines}
<Angle {...angleParams} />
{coords.map((point, i) => (
<StyledMovablePoint
key={"point-" + i}
snapTo={"angles"}
point={point}
onMove={(destination: vec.Vector2) =>
dispatch(actions.angle.movePoint(i, destination))
}
/>
))}
{/* vertex */}
<MovablePoint
point={coords[1]}
constrain={(p) => p}
onMove={(destination: vec.Vector2) =>
dispatch(actions.angle.movePoint(1, destination))
}
/>
{/* side 1 */}
<MovablePoint
point={coords[0]}
constrain={getAngleSideConstraint(
coords[0],
coords[1],
snapDegrees || 1,
)}
onMove={(destination: vec.Vector2) =>
dispatch(actions.angle.movePoint(0, destination))
}
/>
{/* side 2 */}
<MovablePoint
point={coords[2]}
constrain={getAngleSideConstraint(
coords[2],
coords[1],
snapDegrees || 1,
)}
onMove={(destination: vec.Vector2) =>
dispatch(actions.angle.movePoint(2, destination))
}
/>
</>
);
}

const positiveX: vec.Vector2 = [1, 0];
const negativeX: vec.Vector2 = [-1, 0];
const positiveY: vec.Vector2 = [0, 1];
const negativeY: vec.Vector2 = [0, -1];
export function getAngleSideConstraint(
sidePoint: [number, number],
vertex: [number, number],
snapDegrees: number,
): {
up: vec.Vector2;
down: vec.Vector2;
left: vec.Vector2;
right: vec.Vector2;
} {
const currentAngle = calculateAngleInDegrees(vec.sub(sidePoint, vertex));

// Find the rays that start at the current point and point up, down, left
// and right.
const leftRay: Segment = [sidePoint, vec.add(sidePoint, negativeX)];
const rightRay: Segment = [sidePoint, vec.add(sidePoint, positiveX)];
const upRay: Segment = [sidePoint, vec.add(sidePoint, positiveY)];
const downRay: Segment = [sidePoint, vec.add(sidePoint, negativeY)];

// find the angles that lie one snap step clockwise and counter-clockwise
// from the current angle. These are the angles to which the side can be
// moved.
const oneStepCounterClockwise = currentAngle + snapDegrees;
const oneStepClockwise = currentAngle - snapDegrees;

// find the rays that start from the vertex and point in the direction of
// the angles we just computed.
const counterClockwiseRay: Segment = [
vertex,
vec.add(vertex, polar(1, oneStepCounterClockwise)),
];
const clockwiseRay: Segment = [
vertex,
vec.add(vertex, polar(1, oneStepClockwise)),
];

// find the intersections of those rays with the horizontal and vertical
// rays extending from the sidePoint. These intersections are the points to
// which sidePoint can move that will result in a rotation of `snapDegrees`.
const left =
findIntersectionOfRays(leftRay, counterClockwiseRay) ??
findIntersectionOfRays(leftRay, clockwiseRay);
const right =
findIntersectionOfRays(rightRay, counterClockwiseRay) ??
findIntersectionOfRays(rightRay, clockwiseRay);
const up =
findIntersectionOfRays(upRay, counterClockwiseRay) ??
findIntersectionOfRays(upRay, clockwiseRay);
const down =
findIntersectionOfRays(downRay, counterClockwiseRay) ??
findIntersectionOfRays(downRay, clockwiseRay);

return {
up: up ?? sidePoint,
down: down ?? sidePoint,
left: left ?? sidePoint,
right: right ?? sidePoint,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {actions} from "../reducer/interactive-graph-action";
import {getRadius} from "../reducer/interactive-graph-state";
import useGraphConfig from "../reducer/use-graph-config";

import {StyledMovablePoint} from "./components/movable-point";
import {MovablePoint} from "./components/movable-point";
import {useDraggable} from "./use-draggable";
import {
useTransformDimensionsToPixels,
Expand All @@ -29,7 +29,7 @@ export function CircleGraph(props: CircleGraphProps) {
radius={getRadius(graphState)}
onMove={(c) => dispatch(actions.circle.moveCenter(c))}
/>
<StyledMovablePoint
<MovablePoint
point={radiusPoint}
cursor="ew-resize"
onMove={(newRadiusPoint) => {
Expand All @@ -54,7 +54,7 @@ function MovableCircle(props: {
gestureTarget: draggableRef,
point: center,
onMove,
constrain: (p) => snap(snapStep, p),
constrainKeyboardMovement: (p) => snap(snapStep, p),
});

const [centerPx] = useTransformVectorsToPixels(center);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ interface AngleProps {
coords: [vec.Vector2, vec.Vector2];
showAngles: boolean;
allowReflexAngles: boolean;
angleOffsetDeg: number;
snapDegrees: number;
range: [Interval, Interval];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,15 @@ function useControlPoint(
gestureTarget: keyboardHandleRef,
point,
onMove: onMovePoint,
constrain: (p) => snap(snapStep, p),
constrainKeyboardMovement: (p) => snap(snapStep, p),
});

const visiblePointRef = useRef<SVGGElement>(null);
const {dragging} = useDraggable({
gestureTarget: visiblePointRef,
point,
onMove: onMovePoint,
constrain: (p) => snap(snapStep, p),
constrainKeyboardMovement: (p) => snap(snapStep, p),
});

const focusableHandle = (
Expand Down Expand Up @@ -169,7 +169,7 @@ export const Line = (props: LineProps) => {
onMove: (newPoint) => {
onMove(vec.sub(newPoint, start));
},
constrain: (p) => snap(snapStep, p),
constrainKeyboardMovement: (p) => snap(snapStep, p),
});

return (
Expand Down
Loading

0 comments on commit 4c2ace5

Please sign in to comment.