diff --git a/.changeset/hip-turtles-sort.md b/.changeset/hip-turtles-sort.md new file mode 100644 index 00000000000..a4815d7e860 --- /dev/null +++ b/.changeset/hip-turtles-sort.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Create the logarithm graph visual component, add Storybook coverage, SR strings, and equation string for supporting Logarithm graph in Interactive Graph diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts index f9cd55dc760..b073cd9b455 100644 --- a/packages/perseus/src/strings.ts +++ b/packages/perseus/src/strings.ts @@ -522,6 +522,36 @@ export type PerseusStrings = { asymptoteY: string; }) => string; srExponentialAsymptote: ({asymptoteY}: {asymptoteY: string}) => string; + srLogarithmGraph: string; + srLogarithmPoint1: ({x, y}: {x: string; y: string}) => string; + srLogarithmPoint2: ({x, y}: {x: string; y: string}) => string; + srLogarithmDescription: ({ + point1X, + point1Y, + point2X, + point2Y, + asymptoteX, + }: { + point1X: string; + point1Y: string; + point2X: string; + point2Y: string; + asymptoteX: string; + }) => string; + srLogarithmInteractiveElements: ({ + point1X, + point1Y, + point2X, + point2Y, + asymptoteX, + }: { + point1X: string; + point1Y: string; + point2X: string; + point2Y: string; + asymptoteX: string; + }) => string; + srLogarithmAsymptote: ({asymptoteX}: {asymptoteX: string}) => string; srAbsoluteValueGraph: string; srAbsoluteValueVertexPoint: ({x, y}: {x: string; y: string}) => string; srAbsoluteValueSecondPoint: ({x, y}: {x: string; y: string}) => string; @@ -1202,6 +1232,39 @@ export const strings = { message: "Horizontal asymptote at y equals %(asymptoteY)s. Use up and down arrow keys to move.", }, + srLogarithmGraph: { + context: + "Aria label for the container containing a Logarithm function in the interactive graph widget.", + message: "A logarithm function on a coordinate plane.", + }, + srLogarithmPoint1: { + context: + "Aria label for the first Point on the Logarithm function in the interactive graph widget.", + message: "Point 1 at %(x)s comma %(y)s.", + }, + srLogarithmPoint2: { + context: + "Aria label for the second Point on the Logarithm function in the interactive graph widget.", + message: "Point 2 at %(x)s comma %(y)s.", + }, + srLogarithmDescription: { + context: + "Screen reader description of the Logarithm function in the interactive graph widget.", + message: + "The graph shows a logarithm curve passing through point %(point1X)s comma %(point1Y)s and point %(point2X)s comma %(point2Y)s with a vertical asymptote at x equals %(asymptoteX)s.", + }, + srLogarithmInteractiveElements: { + context: + "Screen reader description of all the elements available to interact with within the Logarithm function in the interactive graph widget.", + message: + "Logarithm graph with point 1 at %(point1X)s comma %(point1Y)s, point 2 at %(point2X)s comma %(point2Y)s, and vertical asymptote at x equals %(asymptoteX)s.", + }, + srLogarithmAsymptote: { + context: + "Aria label for the draggable vertical asymptote line in the Logarithm function in the interactive graph widget.", + message: + "Vertical asymptote at x equals %(asymptoteX)s. Use left and right arrow keys to move.", + }, srAbsoluteValueGraph: { context: "Aria label for the container containing an Absolute Value function in the interactive graph widget.", @@ -1612,6 +1675,27 @@ export const mockStrings: PerseusStrings = { `Exponential graph with point 1 at ${point1X} comma ${point1Y}, point 2 at ${point2X} comma ${point2Y}, and horizontal asymptote at y equals ${asymptoteY}.`, srExponentialAsymptote: ({asymptoteY}) => `Horizontal asymptote at y equals ${asymptoteY}. Use up and down arrow keys to move.`, + srLogarithmGraph: "A logarithm function on a coordinate plane.", + srLogarithmPoint1: ({x, y}) => `Point 1 at ${x} comma ${y}.`, + srLogarithmPoint2: ({x, y}) => `Point 2 at ${x} comma ${y}.`, + srLogarithmDescription: ({ + point1X, + point1Y, + point2X, + point2Y, + asymptoteX, + }) => + `The graph shows a logarithm curve passing through point ${point1X} comma ${point1Y} and point ${point2X} comma ${point2Y} with a vertical asymptote at x equals ${asymptoteX}.`, + srLogarithmInteractiveElements: ({ + point1X, + point1Y, + point2X, + point2Y, + asymptoteX, + }) => + `Logarithm graph with point 1 at ${point1X} comma ${point1Y}, point 2 at ${point2X} comma ${point2Y}, and vertical asymptote at x equals ${asymptoteX}.`, + srLogarithmAsymptote: ({asymptoteX}) => + `Vertical asymptote at x equals ${asymptoteX}. Use left and right arrow keys to move.`, srAbsoluteValueGraph: "An absolute value function on a coordinate plane.", srAbsoluteValueVertexPoint: ({x, y}) => `Vertex point at ${x} comma ${y}.`, srAbsoluteValueSecondPoint: ({x, y}) => `Point on arm at ${x} comma ${y}.`, diff --git a/packages/perseus/src/widgets/interactive-graphs/__docs__/interactive-graph.stories.tsx b/packages/perseus/src/widgets/interactive-graphs/__docs__/interactive-graph.stories.tsx index e3b419fb0b9..52144528132 100644 --- a/packages/perseus/src/widgets/interactive-graphs/__docs__/interactive-graph.stories.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/__docs__/interactive-graph.stories.tsx @@ -17,6 +17,7 @@ import { segmentWithAllLockedLineVariations, segmentWithAllLockedRayVariations, exponentialQuestion, + logarithmQuestion, sinusoidQuestion, tangentQuestion, segmentWithLockedEllipses, @@ -117,6 +118,12 @@ export const Sinusoid: Story = { }, }; +export const Logarithm: Story = { + args: { + item: generateTestPerseusItem({question: logarithmQuestion}), + }, +}; + export const Tangent: Story = { args: { item: generateTestPerseusItem({question: tangentQuestion}), diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-asymptote.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-asymptote.test.tsx index 971baa9ec19..8d4c5cf8692 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-asymptote.test.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-asymptote.test.tsx @@ -83,6 +83,32 @@ describe("MovableAsymptote", () => { ); }); + it("blurs the element when a mouse drag ends to clear the focus ring", () => { + // Arrange — start with dragging: true + useDraggable.mockReturnValue({dragging: true}); + const {rerender} = render( + + + , + ); + + const group = screen.getByTestId("movable-asymptote"); + // Simulate that the element received focus during drag + group.focus(); + const blurSpy = jest.spyOn(group, "blur"); + + // Act — drag ends + useDraggable.mockReturnValue({dragging: false}); + rerender( + + + , + ); + + // Assert — blur was called to remove the focus ring + expect(blurSpy).toHaveBeenCalled(); + }); + it("renders the same structure for vertical orientation", () => { // Arrange, Act render( diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-asymptote.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-asymptote.tsx index 9bfcc58a518..f19dd8fd66c 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-asymptote.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-asymptote.tsx @@ -56,6 +56,16 @@ export function MovableAsymptote(props: Props) { constrainKeyboardMovement: constrainKeyboardMovement ?? ((p) => p), }); + // When a mouse drag ends, blur the element so the focus ring doesn't + // persist after the user releases the mouse and moves away. + const wasDragging = React.useRef(false); + React.useEffect(() => { + if (wasDragging.current && !dragging) { + groupRef.current?.blur(); + } + wasDragging.current = dragging; + }, [dragging]); + return ( { ]; const asymptote = 1; const snapStep: vec.Vector2 = [1, 1]; + const range: [vec.Vector2, vec.Vector2] = [ + [-10, 10], + [-10, 10], + ]; it("moves point up by one snap step when valid", () => { // Arrange, Act @@ -208,6 +212,7 @@ describe("getExponentialKeyboardConstraint", () => { asymptote, snapStep, 0, + range, ); // Assert @@ -227,6 +232,7 @@ describe("getExponentialKeyboardConstraint", () => { asymptote, snapStep, 0, + range, ); // Assert — skips y=1 (asymptote) and lands on y=0 @@ -246,11 +252,84 @@ describe("getExponentialKeyboardConstraint", () => { asymptote, snapStep, 0, + range, ); // Assert — skips x=2 and lands on x=3 expect(constraint.right).toEqual([3, 3]); }); + + it("rejects positions where the clamped coord collides with the other point's x", () => { + // Arrange — points at [9,3] and [8,6], asymptote at 1. + // Moving point 0 right: x=10 clamps to 9 (inset max), + // which equals otherPoint[X]=... wait, otherPoint is point 1. + // Actually let's use: point 0 at [8,3], point 1 at [9,6]. + // Moving right: x=9 shares x with point 1 (skip). + // x=10 clamps to 9 (same as point 1). All further clamp to 9. + const edgeCoords: [vec.Vector2, vec.Vector2] = [ + [8, 3], + [9, 6], + ]; + + // Act + const constraint = getExponentialKeyboardConstraint( + edgeCoords, + asymptote, + snapStep, + 0, + range, + ); + + // Assert — no valid right move, falls back to original position + expect(constraint.right).toEqual([8, 3]); + }); + + it("stays put when all upward positions would cause a clamped reflection collision", () => { + // Arrange — asymptote at y=8, points at [3,7] and [1,4]. + // Moving point 0 up: y=8 is the asymptote (skip), y=9 crosses + // the asymptote so reflectedY = 2*8-4 = 12, which clamps to 9 + // (yMax - snapStep). clampedReflectedY(9) === clampedY(9). Skip. + // y=10 clamps to 9 too. No valid upward position. + const edgeCoords: [vec.Vector2, vec.Vector2] = [ + [3, 7], + [1, 4], + ]; + + // Act + const constraint = getExponentialKeyboardConstraint( + edgeCoords, + 8, + snapStep, + 0, + range, + ); + + // Assert — no valid up move, falls back to original position + expect(constraint.up).toEqual([3, 7]); + }); + + it("skips positions where the clamped asymptote check catches out-of-bounds coord", () => { + // Arrange — asymptote at y=9 (one step from edge). + // Point 0 at [3,8], moving up: y=9 is asymptote (skip). + // y=10 clamps to 9 which also equals asymptote. Skip. + // No valid upward position. + const edgeCoords: [vec.Vector2, vec.Vector2] = [ + [3, 8], + [1, 6], + ]; + + // Act + const constraint = getExponentialKeyboardConstraint( + edgeCoords, + 9, + snapStep, + 0, + range, + ); + + // Assert — no valid up move + expect(constraint.up).toEqual([3, 8]); + }); }); describe("constrainAsymptoteKeyboard", () => { diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/exponential.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/exponential.tsx index 6ed7944ef1a..72fddb558b0 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/exponential.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/exponential.tsx @@ -2,22 +2,27 @@ import { coefficients as kmathCoefficients, type ExponentialCoefficient, } from "@khanacademy/kmath"; -import {Plot, vec} from "mafs"; +import {Plot} from "mafs"; import * as React from "react"; import { usePerseusI18n, type I18nContextType, } from "../../../components/i18n-context"; -import {snap, X, Y} from "../math"; +import {X, Y, snap} from "../math"; import {actions} from "../reducer/interactive-graph-action"; import useGraphConfig from "../reducer/use-graph-config"; +import {bound} from "../utils"; import {MovableAsymptote} from "./components/movable-asymptote"; import {MovablePoint} from "./components/movable-point"; import SRDescInSVG from "./components/sr-description-within-svg"; import {srFormatNumber} from "./screenreader-text"; import {useTransformVectorsToPixels} from "./use-transform"; +import { + getAsymptoteGraphKeyboardConstraint, + constrainAsymptoteKeyboardMovement, +} from "./utils"; import type { ExponentialGraphState, @@ -26,6 +31,7 @@ import type { InteractiveGraphElementSuite, } from "../types"; import type {Coord} from "@khanacademy/perseus-core"; +import type {Interval, vec} from "mafs"; const {getExponentialCoefficients} = kmathCoefficients; @@ -131,6 +137,7 @@ function ExponentialGraph(props: ExponentialGraphProps) { asymptote, snapStep, i, + range, )} onMove={(destination) => dispatch(actions.exponential.movePoint(i, destination)) @@ -144,100 +151,75 @@ function ExponentialGraph(props: ExponentialGraphProps) { ); } -// Keyboard constraint for the asymptote. When the next snapped position -// would land between or on the curve points, snap past all of them in the -// direction of travel. Mirrors logarithm's constrainAsymptoteKeyboard with -// Y-axis instead of X. export const constrainAsymptoteKeyboard = ( p: vec.Vector2, coords: ReadonlyArray, snapStep: vec.Vector2, -): vec.Vector2 => { - const snapped = snap(snapStep, p); - let newY = snapped[Y]; - const stepY = snapStep[Y]; - - const topMost = Math.max(coords[0][Y], coords[1][Y]); - const bottomMost = Math.min(coords[0][Y], coords[1][Y]); - - const allAbove = coords[0][Y] > newY && coords[1][Y] > newY; - const allBelow = coords[0][Y] < newY && coords[1][Y] < newY; - - if (!allAbove && !allBelow) { - const midpoint = (topMost + bottomMost) / 2; - if (newY >= midpoint) { - newY = topMost + stepY; - } else { - newY = bottomMost - stepY; - } - } - - // Can't land exactly on a point — skip one more step - if (newY === coords[0][Y] || newY === coords[1][Y]) { - if (newY >= (topMost + bottomMost) / 2) { - newY += stepY; - } else { - newY -= stepY; - } - } - - return [snapped[X], newY]; -}; +): vec.Vector2 => + constrainAsymptoteKeyboardMovement(p, coords, snapStep, "horizontal"); export const getExponentialKeyboardConstraint = ( coords: ReadonlyArray, asymptote: number, snapStep: vec.Vector2, pointIndex: number, + range: [Interval, Interval], ): { up: vec.Vector2; down: vec.Vector2; left: vec.Vector2; right: vec.Vector2; } => { - const coordToBeMoved = coords[pointIndex]; const otherPoint = coords[1 - pointIndex]; const asymptoteY = asymptote; - const isValidPosition = (coord: vec.Vector2): boolean => { - if (coord[Y] === asymptoteY) { - return false; - } - if (coord[X] === otherPoint[X]) { - return false; - } - // Crossing the asymptote is allowed — the reducer reflects - // the other point so both end up on the same side. - return true; - }; - - const movePointWithConstraint = ( - moveFunc: (coord: vec.Vector2) => vec.Vector2, - ): vec.Vector2 => { - let movedCoord = moveFunc(coordToBeMoved); - for (let i = 0; i < 3 && !isValidPosition(movedCoord); i++) { - movedCoord = moveFunc(movedCoord); - } - if (!isValidPosition(movedCoord)) { - return coordToBeMoved; - } - return movedCoord; - }; + return getAsymptoteGraphKeyboardConstraint( + coords, + snapStep, + pointIndex, + (coord) => { + // The reducer clamps the destination via boundAndSnapToGrid + // before applying its own collision checks. We must predict + // the clamped position to avoid accepting coords that the + // reducer will silently reject. + const clamped = snap( + snapStep, + bound({snapStep, range, point: coord}), + ); + const clampedX = clamped[X]; + const clampedY = clamped[Y]; - return { - up: movePointWithConstraint((coord) => - vec.add(coord, [0, snapStep[Y]]), - ), - down: movePointWithConstraint((coord) => - vec.sub(coord, [0, snapStep[Y]]), - ), - left: movePointWithConstraint((coord) => - vec.sub(coord, [snapStep[X], 0]), - ), - right: movePointWithConstraint((coord) => - vec.add(coord, [snapStep[X], 0]), - ), - }; + // Point cannot land on the horizontal asymptote + if (coord[Y] === asymptoteY || clampedY === asymptoteY) { + return false; + } + // Both points must have different x-values + if (coord[X] === otherPoint[X] || clampedX === otherPoint[X]) { + return false; + } + // When the move crosses the asymptote, the reducer will + // reflect the other point. Check that the reflected Y + // doesn't collide with the proposed coord's Y. + const currentPoint = coords[pointIndex]; + const currentSide = currentPoint[Y] > asymptoteY; + const proposedSide = coord[Y] > asymptoteY; + if (currentSide !== proposedSide) { + const reflectedY = 2 * asymptoteY - otherPoint[Y]; + const clampedReflectedY = snap( + snapStep, + bound({snapStep, range, point: [0, reflectedY]}), + )[Y]; + if ( + reflectedY === coord[Y] || + clampedReflectedY === coord[Y] || + clampedReflectedY === clampedY + ) { + return false; + } + } + return true; + }, + ); }; // Plot an exponential of the form: f(x) = a * e^(b * x) + c diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/logarithm.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/logarithm.test.tsx new file mode 100644 index 00000000000..f5f499e77aa --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/logarithm.test.tsx @@ -0,0 +1,383 @@ +import {render, screen} from "@testing-library/react"; +import * as React from "react"; + +import * as Dependencies from "../../../dependencies"; +import {testDependencies} from "../../../testing/test-dependencies"; +import {MafsGraph} from "../mafs-graph"; +import {getBaseMafsGraphPropsForTests} from "../utils"; + +import { + constrainAsymptoteKeyboard, + getLogarithmKeyboardConstraint, +} from "./logarithm"; + +import type {InteractiveGraphState} from "../types"; +import type {vec} from "mafs"; + +const baseMafsGraphProps = getBaseMafsGraphPropsForTests(); + +// Curve right of asymptote: f(-4)=-3, f(-5)=-7, asymptote x=-6 +const baseLogarithmState: InteractiveGraphState = { + type: "logarithm", + coords: [ + [-4, -3], + [-5, -7], + ], + asymptote: -6, + hasBeenInteractedWith: false, + range: [ + [-10, 10], + [-10, 10], + ], + snapStep: [1, 1], +}; + +describe("Logarithm graph screen reader", () => { + beforeEach(() => { + jest.spyOn(Dependencies, "getDependencies").mockReturnValue( + testDependencies, + ); + }); + + it("has the correct aria-label on the graph element", () => { + // Arrange, Act + render( + , + ); + + // Assert + expect( + screen.getByLabelText( + "A logarithm function on a coordinate plane.", + ), + ).toBeInTheDocument(); + }); + + it("labels point 1 with its coordinates", () => { + // Arrange, Act + render( + , + ); + + // Assert + expect( + screen.getByRole("button", {name: "Point 1 at -4 comma -3."}), + ).toBeInTheDocument(); + }); + + it("labels point 2 with its coordinates", () => { + // Arrange, Act + render( + , + ); + + // Assert + expect( + screen.getByRole("button", {name: "Point 2 at -5 comma -7."}), + ).toBeInTheDocument(); + }); + + it("labels the asymptote with its x-value and keyboard instructions", () => { + // Arrange, Act + render( + , + ); + + // Assert + expect( + screen.getByRole("button", { + name: "Vertical asymptote at x equals -6. Use left and right arrow keys to move.", + }), + ).toBeInTheDocument(); + }); + + it("describes the graph with point positions and asymptote", () => { + // Arrange, Act + render( + , + ); + + // Assert + expect( + screen.getByLabelText( + "A logarithm function on a coordinate plane.", + ), + ).toHaveAccessibleDescription( + "The graph shows a logarithm curve passing through point -4 comma -3 and point -5 comma -7 with a vertical asymptote at x equals -6.", + ); + }); + + it("updates the graph description when point positions change", () => { + // Arrange, Act + render( + , + ); + + // Assert + expect( + screen.getByLabelText( + "A logarithm function on a coordinate plane.", + ), + ).toHaveAccessibleDescription( + "The graph shows a logarithm curve passing through point -2 comma 4 and point -3 comma 2 with a vertical asymptote at x equals -6.", + ); + }); + + it("updates the graph description when the asymptote changes", () => { + // Arrange, Act + render( + , + ); + + // Assert + expect( + screen.getByLabelText( + "A logarithm function on a coordinate plane.", + ), + ).toHaveAccessibleDescription( + "The graph shows a logarithm curve passing through point -4 comma -3 and point -5 comma -7 with a vertical asymptote at x equals -8.", + ); + }); + + it("describes the interactive elements for the graph", () => { + // Arrange, Act + render( + , + ); + + // Assert + expect( + screen.getByText( + "Interactive elements: Logarithm graph with point 1 at -4 comma -3, point 2 at -5 comma -7, and vertical asymptote at x equals -6.", + ), + ).toBeInTheDocument(); + }); + + it("updates the interactive elements description when state changes", () => { + // Arrange, Act + render( + , + ); + + // Assert + expect( + screen.getByText( + "Interactive elements: Logarithm graph with point 1 at 3 comma 5, point 2 at 4 comma 7, and vertical asymptote at x equals 2.", + ), + ).toBeInTheDocument(); + }); +}); + +describe("getLogarithmKeyboardConstraint", () => { + const coords: [vec.Vector2, vec.Vector2] = [ + [-4, -3], + [-5, -7], + ]; + const asymptote = -6; + const snapStep: vec.Vector2 = [1, 1]; + const range: [vec.Vector2, vec.Vector2] = [ + [-10, 10], + [-10, 10], + ]; + + it("moves point up by one snap step when valid", () => { + // Arrange, Act + const constraint = getLogarithmKeyboardConstraint( + coords, + asymptote, + snapStep, + 0, + range, + ); + + // Assert + expect(constraint.up).toEqual([-4, -2]); + }); + + it("skips positions on the asymptote when moving left", () => { + // Arrange — point at x=-5, asymptote at x=-6: moving left would hit x=-6 then x=-7 + const nearAsymptoteCoords: [vec.Vector2, vec.Vector2] = [ + [-5, -3], + [-4, -7], + ]; + + // Act + const constraint = getLogarithmKeyboardConstraint( + nearAsymptoteCoords, + asymptote, + snapStep, + 0, + range, + ); + + // Assert — skips x=-6 (asymptote) and lands on x=-7 + expect(constraint.left).toEqual([-7, -3]); + }); + + it("skips positions that share y-coordinate with the other point when moving down", () => { + // Arrange — point 0 at y=-6, moving down would hit y=-7 which is point 1's y + const alignedCoords: [vec.Vector2, vec.Vector2] = [ + [-4, -6], + [-5, -7], + ]; + + // Act + const constraint = getLogarithmKeyboardConstraint( + alignedCoords, + asymptote, + snapStep, + 0, + range, + ); + + // Assert — skips y=-7 and lands on y=-8 + expect(constraint.down).toEqual([-4, -8]); + }); + + it("skips positions that share x-coordinate with the other point when moving left", () => { + // Arrange — point 0 at x=-4, moving left would hit x=-5 which is point 1's x + const coords: [vec.Vector2, vec.Vector2] = [ + [-4, -3], + [-5, -7], + ]; + + // Act + const constraint = getLogarithmKeyboardConstraint( + coords, + asymptote, + snapStep, + 0, + range, + ); + + // Assert — skips x=-5 (same x as point 1), x=-6 (asymptote), + // and x=-7 (reflected other point would collide), lands on x=-8 + expect(constraint.left).toEqual([-8, -3]); + }); + + it("stays put when all rightward positions would cause a clamped collision", () => { + // Arrange — asymptote at x=8, points at [7,3] and [4,1]. + // Moving point 0 right: x=8 is the asymptote, x=9 crosses the + // asymptote so reflectedX = 2*8-4 = 12 which clamps to 9 + // (same as clamped coord), and x>=10 all clamp to 9 as well. + // No valid rightward position exists, so the point stays put. + const edgeCoords: [vec.Vector2, vec.Vector2] = [ + [7, 3], + [4, 1], + ]; + const edgeRange: [vec.Vector2, vec.Vector2] = [ + [-10, 10], + [-10, 10], + ]; + + // Act + const constraint = getLogarithmKeyboardConstraint( + edgeCoords, + 8, + snapStep, + 0, + edgeRange, + ); + + // Assert — no valid right move, falls back to original position + expect(constraint.right).toEqual([7, 3]); + }); + + it("rejects positions where the clamped coord collides with the other point", () => { + // Arrange — points at [8,3] and [9,1], asymptote at -5. + // Moving point 0 right: x=9 shares x with otherPoint (skip). + // x=10 clamps to 9 (inset max), which also equals otherPoint[X]. + // All further attempts clamp to 9 too. Point stays put. + const edgeCoords: [vec.Vector2, vec.Vector2] = [ + [8, 3], + [9, 1], + ]; + const edgeRange: [vec.Vector2, vec.Vector2] = [ + [-10, 10], + [-10, 10], + ]; + + // Act + const constraint = getLogarithmKeyboardConstraint( + edgeCoords, + -5, + snapStep, + 0, + edgeRange, + ); + + // Assert — no valid right move, falls back to original position + expect(constraint.right).toEqual([8, 3]); + }); +}); + +describe("constrainAsymptoteKeyboard", () => { + const snapStep: vec.Vector2 = [1, 1]; + + it("allows the asymptote to move freely when not between or on the curve points", () => { + // Arrange — points at x=-4 and x=-5, asymptote moving to x=-7 (left of both) + const coords: [vec.Vector2, vec.Vector2] = [ + [-4, -3], + [-5, -7], + ]; + + // Act + const result = constrainAsymptoteKeyboard([-7, 0], coords, snapStep); + + // Assert — x=-7 is valid (left of both points) + expect(result).toEqual([-7, 0]); + }); + + it("snaps the asymptote past both points when it would land between them", () => { + // Arrange — points at x=-4 and x=-2, asymptote trying to land at x=-3 (between) + const coords: [vec.Vector2, vec.Vector2] = [ + [-4, -3], + [-2, -7], + ]; + + // Act + const result = constrainAsymptoteKeyboard([-3, 0], coords, snapStep); + + // Assert — snapped to one step past the points + expect(result[0] < -4 || result[0] > -2).toBe(true); + }); + + it("skips an x-value exactly equal to a curve point", () => { + // Arrange — points at x=-4 and x=-2, asymptote trying to land exactly at x=-4 + const coords: [vec.Vector2, vec.Vector2] = [ + [-4, -3], + [-2, -7], + ]; + + // Act + const result = constrainAsymptoteKeyboard([-4, 0], coords, snapStep); + + // Assert — must not land exactly on x=-4 + expect(result[0]).not.toBe(-4); + }); +}); diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/logarithm.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/logarithm.tsx new file mode 100644 index 00000000000..ef7362c1fec --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/logarithm.tsx @@ -0,0 +1,304 @@ +import { + coefficients as kmathCoefficients, + type LogarithmCoefficient, +} from "@khanacademy/kmath"; +import {Plot} from "mafs"; +import * as React from "react"; + +import { + usePerseusI18n, + type I18nContextType, +} from "../../../components/i18n-context"; +import {X, Y, snap} from "../math"; +import {actions} from "../reducer/interactive-graph-action"; +import useGraphConfig from "../reducer/use-graph-config"; +import {bound} from "../utils"; + +import {MovableAsymptote} from "./components/movable-asymptote"; +import {MovablePoint} from "./components/movable-point"; +import SRDescInSVG from "./components/sr-description-within-svg"; +import {srFormatNumber} from "./screenreader-text"; +import {useTransformVectorsToPixels} from "./use-transform"; +import { + getAsymptoteGraphKeyboardConstraint, + constrainAsymptoteKeyboardMovement, +} from "./utils"; + +import type { + LogarithmGraphState, + MafsGraphProps, + Dispatch, + InteractiveGraphElementSuite, +} from "../types"; +import type {Coord} from "@khanacademy/perseus-core"; +import type {Interval, vec} from "mafs"; + +const {getLogarithmCoefficients} = kmathCoefficients; + +export function renderLogarithmGraph( + state: LogarithmGraphState, + dispatch: Dispatch, + i18n: I18nContextType, +): InteractiveGraphElementSuite { + return { + graph: , + interactiveElementsDescription: getLogarithmDescription(state, i18n), + }; +} + +type LogarithmGraphProps = MafsGraphProps; + +function LogarithmGraph(props: LogarithmGraphProps) { + const {dispatch, graphState} = props; + const {interactiveColor, range} = useGraphConfig(); + const i18n = usePerseusI18n(); + const id = React.useId(); + const descriptionId = id + "-description"; + + const {coords, asymptote, snapStep} = graphState; + + // Cache last valid coefficients so the graph doesn't break during + // transient invalid states (e.g. mid-drag where points share a y-value). + const coeffRef = React.useRef({ + a: 1, + b: 1, + c: 0, + }); + const coeffs = getLogarithmCoefficients(coords, asymptote); + if (coeffs !== undefined) { + coeffRef.current = coeffs; + } + + const asymptoteX = asymptote; + const xMin = range[0][0]; + const xMax = range[0][1]; + const yMin = range[1][0]; + const yMax = range[1][1]; + const yPadding = (yMax - yMin) * 2; + + // Determine which side of the asymptote the points are on + const pointsRightOfAsymptote = coords[0][X] > asymptoteX; + + // Aria strings + const { + srLogarithmGraph, + srLogarithmDescription, + srLogarithmPoint1, + srLogarithmPoint2, + srLogarithmAsymptote, + } = describeLogarithmGraph(graphState, i18n); + + // The asymptote is a full-height vertical line. + const asymptoteBottom: vec.Vector2 = [asymptoteX, yMin]; + const asymptoteTop: vec.Vector2 = [asymptoteX, yMax]; + const asymptoteMidY = (yMin + yMax) / 2; + const asymptoteMid: vec.Vector2 = [asymptoteX, asymptoteMidY]; + + const [bottomPx, topPx, midPx] = useTransformVectorsToPixels( + asymptoteBottom, + asymptoteTop, + asymptoteMid, + ); + + return ( + + + dispatch(actions.logarithm.moveCenter(newPoint)) + } + constrainKeyboardMovement={(p) => + constrainAsymptoteKeyboard(p, coords, snapStep) + } + orientation="vertical" + ariaLabel={srLogarithmAsymptote} + /> + { + const y = computeLogarithm(coeffRef.current, x); + if (isNaN(y)) { + return NaN; + } + if (y < yMin - yPadding || y > yMax + yPadding) { + return NaN; + } + return y; + }} + color={interactiveColor} + svgPathProps={{ + "aria-hidden": true, + }} + domain={ + pointsRightOfAsymptote + ? [asymptoteX + 0.001, xMax] + : [xMin, asymptoteX - 0.001] + } + /> + {coords.map((coord, i) => ( + + dispatch(actions.logarithm.movePoint(i, destination)) + } + /> + ))} + + {srLogarithmDescription} + + + ); +} + +export const constrainAsymptoteKeyboard = ( + p: vec.Vector2, + coords: ReadonlyArray, + snapStep: vec.Vector2, +): vec.Vector2 => + constrainAsymptoteKeyboardMovement(p, coords, snapStep, "vertical"); + +export const getLogarithmKeyboardConstraint = ( + coords: ReadonlyArray, + asymptote: number, + snapStep: vec.Vector2, + pointIndex: number, + range: [Interval, Interval], +): { + up: vec.Vector2; + down: vec.Vector2; + left: vec.Vector2; + right: vec.Vector2; +} => { + const otherPoint = coords[1 - pointIndex]; + const asymptoteX = asymptote; + + return getAsymptoteGraphKeyboardConstraint( + coords, + snapStep, + pointIndex, + (coord) => { + // The reducer clamps the destination via boundAndSnapToGrid + // before applying its own collision checks. We must predict + // the clamped position to avoid accepting coords that the + // reducer will silently reject. + const clamped = snap( + snapStep, + bound({snapStep, range, point: coord}), + ); + const clampedX = clamped[X]; + const clampedY = clamped[Y]; + + // Point cannot land on the vertical asymptote + if (coord[X] === asymptoteX || clampedX === asymptoteX) { + return false; + } + // Both points must have different x-values + // (same x makes the coefficient computation degenerate) + if (coord[X] === otherPoint[X] || clampedX === otherPoint[X]) { + return false; + } + // Both points must have different y-values + if (coord[Y] === otherPoint[Y] || clampedY === otherPoint[Y]) { + return false; + } + // When the move crosses the asymptote, the reducer will + // reflect the other point. Check that the reflected X + // doesn't collide with the proposed coord's X. + const currentPoint = coords[pointIndex]; + const currentSide = currentPoint[X] > asymptoteX; + const proposedSide = coord[X] > asymptoteX; + if (currentSide !== proposedSide) { + const reflectedX = 2 * asymptoteX - otherPoint[X]; + const clampedReflectedX = snap( + snapStep, + bound({snapStep, range, point: [reflectedX, 0]}), + )[X]; + if ( + reflectedX === coord[X] || + clampedReflectedX === coord[X] || + clampedReflectedX === clampedX + ) { + return false; + } + } + return true; + }, + ); +}; + +// Plot a logarithm of the form: f(x) = a * ln(b * x + c) +const computeLogarithm = function ( + coefficients: LogarithmCoefficient, + x: number, +) { + const {a, b, c} = coefficients; + const arg = b * x + c; + if (arg <= 0) { + return NaN; + } + return a * Math.log(arg); +}; + +function getLogarithmDescription( + state: LogarithmGraphState, + i18n: I18nContextType, +): string { + const strings = describeLogarithmGraph(state, i18n); + return strings.srLogarithmInteractiveElements; +} + +function describeLogarithmGraph( + state: LogarithmGraphState, + i18n: I18nContextType, +): Record { + const {strings, locale} = i18n; + const {coords, asymptote} = state; + const [point1, point2] = coords; + + const formattedPoint1 = { + x: srFormatNumber(point1[X], locale), + y: srFormatNumber(point1[Y], locale), + }; + const formattedPoint2 = { + x: srFormatNumber(point2[X], locale), + y: srFormatNumber(point2[Y], locale), + }; + const asymptoteXFormatted = srFormatNumber(asymptote, locale); + + return { + srLogarithmGraph: strings.srLogarithmGraph, + srLogarithmDescription: strings.srLogarithmDescription({ + point1X: formattedPoint1.x, + point1Y: formattedPoint1.y, + point2X: formattedPoint2.x, + point2Y: formattedPoint2.y, + asymptoteX: asymptoteXFormatted, + }), + srLogarithmAsymptote: strings.srLogarithmAsymptote({ + asymptoteX: asymptoteXFormatted, + }), + srLogarithmPoint1: strings.srLogarithmPoint1(formattedPoint1), + srLogarithmPoint2: strings.srLogarithmPoint2(formattedPoint2), + srLogarithmInteractiveElements: strings.srInteractiveElements({ + elements: strings.srLogarithmInteractiveElements({ + point1X: srFormatNumber(point1[X], locale), + point1Y: srFormatNumber(point1[Y], locale), + point2X: srFormatNumber(point2[X], locale), + point2Y: srFormatNumber(point2[Y], locale), + asymptoteX: asymptoteXFormatted, + }), + }), + }; +} diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts index 57f7f77aaef..b53033d84d5 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts @@ -1,5 +1,7 @@ import {vec} from "mafs"; +import {snap} from "../math"; + import {srFormatNumber} from "./screenreader-text"; import type {PerseusStrings} from "../../../strings"; @@ -379,3 +381,98 @@ export function calculateScaledRadius(range: [Interval, Interval]): number { // - Setting this lower would make the arc too small to be visible. return minSpan * 0.06; } + +/** + * Shared keyboard constraint logic for asymptote-based graph points + * (exponential, logarithm). Computes the next valid position for each + * arrow-key direction, skipping up to 3 snap steps to avoid positions + * rejected by the caller's `isValidPosition` predicate. + * + * The per-graph validity rules differ (exponential checks Y vs asymptote and + * X vs other point; logarithm checks X vs asymptote and Y vs other point), + * so they are injected via the callback. + */ +export function getAsymptoteGraphKeyboardConstraint( + coords: ReadonlyArray, + snapStep: vec.Vector2, + pointIndex: number, + isValidPosition: (coord: vec.Vector2) => boolean, +): { + up: vec.Vector2; + down: vec.Vector2; + left: vec.Vector2; + right: vec.Vector2; +} { + const coordToBeMoved = coords[pointIndex]; + + const movePointWithConstraint = ( + moveFunc: (coord: vec.Vector2) => vec.Vector2, + ): vec.Vector2 => { + let movedCoord = moveFunc(coordToBeMoved); + for (let i = 0; i < 3 && !isValidPosition(movedCoord); i++) { + movedCoord = moveFunc(movedCoord); + } + if (!isValidPosition(movedCoord)) { + return coordToBeMoved; + } + return movedCoord; + }; + + return { + up: movePointWithConstraint((coord) => + vec.add(coord, [0, snapStep[1]]), + ), + down: movePointWithConstraint((coord) => + vec.sub(coord, [0, snapStep[1]]), + ), + left: movePointWithConstraint((coord) => + vec.sub(coord, [snapStep[0], 0]), + ), + right: movePointWithConstraint((coord) => + vec.add(coord, [snapStep[0], 0]), + ), + }; +} + +/** + * Shared keyboard constraint for asymptote movement (exponential, logarithm). + * When the next snapped position would land between or on the curve points, + * snaps past all of them in the direction of travel using a midpoint heuristic. + * + * @param orientation - "horizontal" for exponential (asymptote moves on Y-axis), + * "vertical" for logarithm (asymptote moves on X-axis). + */ +export function constrainAsymptoteKeyboardMovement( + p: vec.Vector2, + coords: ReadonlyArray, + snapStep: vec.Vector2, + orientation: "horizontal" | "vertical", +): vec.Vector2 { + const snapped = snap(snapStep, p); + + // Select the axis: horizontal asymptote constrains on Y, vertical on X + const axis = orientation === "horizontal" ? 1 : 0; + const otherAxis = orientation === "horizontal" ? 0 : 1; + let newVal = snapped[axis]; + const step = snapStep[axis]; + + const maxVal = Math.max(coords[0][axis], coords[1][axis]); + const minVal = Math.min(coords[0][axis], coords[1][axis]); + + const allGreater = coords[0][axis] > newVal && coords[1][axis] > newVal; + const allLesser = coords[0][axis] < newVal && coords[1][axis] < newVal; + + if (!allGreater && !allLesser) { + const midpoint = (maxVal + minVal) / 2; + if (newVal >= midpoint) { + newVal = maxVal + step; + } else { + newVal = minVal - step; + } + } + + const result: vec.Vector2 = [0, 0]; + result[axis] = newVal; + result[otherAxis] = snapped[otherAxis]; + return result; +} diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx index 11a3c59524d..7a31b6e989d 100644 --- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx @@ -19,6 +19,7 @@ import {scorePerseusItemTesting} from "../../util/test-utils"; import {renderQuestion} from "../__testutils__/renderQuestion"; import {sinusoidQuestion} from "../grapher/grapher.testdata"; +import InteractiveGraphExports from "./interactive-graph"; import {interactiveGraphQuestionBuilder} from "./interactive-graph-question-builder"; import { angleQuestion, @@ -1937,3 +1938,72 @@ describe("Interactive Graph", function () { ); }); }); + +describe("getLogarithmEquationString", () => { + const InteractiveGraph = InteractiveGraphExports.widget; + + function makeProps(coords: [Coord, Coord], asymptote: number) { + return { + userInput: { + type: "logarithm", + coords, + asymptote, + }, + } as unknown as Parameters< + typeof InteractiveGraph.getLogarithmEquationString + >[0]; + } + + it("omits the constant term when asymptote is 0 (c === 0)", () => { + // Arrange — asymptote=0 produces c=0 + const props = makeProps( + [ + [3, 2], + [5, 4], + ], + 0, + ); + + // Act + const equation = InteractiveGraph.getLogarithmEquationString(props); + + // Assert — should NOT contain "+ 0.000" + expect(equation).not.toContain("+ 0.000"); + expect(equation).toMatch(/ln\(\d+\.\d+x\)/); + }); + + it("shows subtracted constant when c < 0", () => { + // Arrange — asymptote=2 produces a negative c + const props = makeProps( + [ + [3, 2], + [5, 4], + ], + 2, + ); + + // Act + const equation = InteractiveGraph.getLogarithmEquationString(props); + + // Assert — should contain "x - " for negative c + expect(equation).toContain("x - "); + expect(equation).not.toContain("x + -"); + }); + + it("shows added constant when c > 0", () => { + // Arrange — asymptote=-2 produces a positive c + const props = makeProps( + [ + [3, 2], + [5, 4], + ], + -2, + ); + + // Act + const equation = InteractiveGraph.getLogarithmEquationString(props); + + // Assert — should contain "x + " for positive c + expect(equation).toContain("x + "); + }); +}); diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.tsx index e4e0a476036..dcc58295a00 100644 --- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.tsx @@ -53,6 +53,7 @@ const { getTangentCoefficients, getQuadraticCoefficients, getExponentialCoefficients, + getLogarithmCoefficients, } = coefficients; const {getLineEquation, getLineIntersectionString, magnitude, vector} = @@ -614,7 +615,7 @@ class InteractiveGraph extends React.Component { case "tangent": return InteractiveGraph.getTangentEquationString(props); case "logarithm": - return ""; + return InteractiveGraph.getLogarithmEquationString(props); default: throw new UnreachableCaseError(type); } @@ -724,7 +725,7 @@ class InteractiveGraph extends React.Component { [0.75, 0.75], ]; // @ts-expect-error - TS2345 - Argument of type 'number[][]' is not assignable to parameter of type 'readonly Coord[]'. - return InteractiveGraph.pointsFromNormalized(props, coords); + return InteractiveGraph.pointsFromNormalized(props, coords, true); } static getExponentialEquationString(props: Props): string { @@ -749,6 +750,42 @@ class InteractiveGraph extends React.Component { ); } + static defaultLogarithmCoords(props: Props): Coord[] { + const coords: Coord[] = [ + [0.55, 0.55], + [0.75, 0.75], + ]; + return InteractiveGraph.pointsFromNormalized(props, coords, true); + } + + static getLogarithmEquationString(props: Props): string { + const coords = + // @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'. + props.userInput.coords || + InteractiveGraph.defaultLogarithmCoords(props); + const asymptote = + // @ts-expect-error - TS2339 - Property 'asymptote' does not exist on type 'PerseusGraphType'. + props.userInput.asymptote ?? 0; + const coeffs = getLogarithmCoefficients(coords, asymptote); + if (coeffs == null) { + return "y = ln(x)"; + } + const cStr = + coeffs.c === 0 + ? "x" + : coeffs.c < 0 + ? "x - " + Math.abs(coeffs.c).toFixed(3) + : "x + " + coeffs.c.toFixed(3); + return ( + "y = " + + coeffs.a.toFixed(3) + + "ln(" + + coeffs.b.toFixed(3) + + cStr + + ")" + ); + } + static getAbsoluteValueEquationString(props: Props): string { const userInput = props.userInput; if (userInput.type !== "absolute-value" || !userInput.coords) { diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx index 0c425715946..528aaf70597 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx @@ -38,6 +38,7 @@ import {SvgDefs} from "./graphs/components/text-label"; import {renderExponentialGraph} from "./graphs/exponential"; import {renderLinearGraph} from "./graphs/linear"; import {renderLinearSystemGraph} from "./graphs/linear-system"; +import {renderLogarithmGraph} from "./graphs/logarithm"; import {renderPointGraph} from "./graphs/point"; import {renderPolygonGraph} from "./graphs/polygon"; import {renderQuadraticGraph} from "./graphs/quadratic"; @@ -778,7 +779,7 @@ const renderGraphElements = (props: { case "tangent": return renderTangentGraph(state, dispatch, i18n); case "logarithm": - return {graph: null, interactiveElementsDescription: null}; + return renderLogarithmGraph(state, dispatch, i18n); default: throw new UnreachableCaseError(type); } diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts index b24bb05716f..c4dc75929e6 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts @@ -891,6 +891,10 @@ function doMoveCenter( } } + if (newX === state.asymptote) { + return state; + } + return { ...state, hasBeenInteractedWith: true,