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,