Skip to content

Commit

Permalink
Add keyboard controls for angle graphs
Browse files Browse the repository at this point in the history
  • Loading branch information
benchristel committed Aug 8, 2024
1 parent b8a342c commit ec33ba9
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))

Check warning on line 93 in packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx#L93

Added line #L93 was not covered by tests
}
/>
{/* side 1 */}
<MovablePoint
point={coords[0]}
constrain={getAngleSideConstraint(
coords[0],
coords[1],
snapDegrees || 1,
)}
onMove={(destination: vec.Vector2) =>
dispatch(actions.angle.movePoint(0, destination))

Check warning on line 105 in packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx#L105

Added line #L105 was not covered by tests
}
/>
{/* side 2 */}
<MovablePoint
point={coords[2]}
constrain={getAngleSideConstraint(
coords[2],
coords[1],
snapDegrees || 1,
)}
onMove={(destination: vec.Vector2) =>
dispatch(actions.angle.movePoint(2, destination))

Check warning on line 117 in packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx

View check run for this annotation

Codecov / codecov/patch

packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx#L117

Added line #L117 was not covered by tests
}
/>
</>
);
}

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 ec33ba9

Please sign in to comment.