Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hip-turtles-sort.md
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions packages/perseus/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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}.`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
segmentWithAllLockedLineVariations,
segmentWithAllLockedRayVariations,
exponentialQuestion,
logarithmQuestion,
sinusoidQuestion,
tangentQuestion,
segmentWithLockedEllipses,
Expand Down Expand Up @@ -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}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,32 @@ describe("MovableAsymptote", () => {
);
});

it("blurs the element when a mouse drag ends to clear the focus ring", () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love it! Thank you for fixing the issue

// Arrange — start with dragging: true
useDraggable.mockReturnValue({dragging: true});
const {rerender} = render(
<Mafs width={200} height={200}>
<MovableAsymptote {...defaultProps} />
</Mafs>,
);

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(
<Mafs width={200} height={200}>
<MovableAsymptote {...defaultProps} />
</Mafs>,
);

// Assert — blur was called to remove the focus ring
expect(blurSpy).toHaveBeenCalled();
});

it("renders the same structure for vertical orientation", () => {
// Arrange, Act
render(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<g
ref={groupRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ describe("getExponentialKeyboardConstraint", () => {
];
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
Expand All @@ -208,6 +212,7 @@ describe("getExponentialKeyboardConstraint", () => {
asymptote,
snapStep,
0,
range,
);

// Assert
Expand All @@ -227,6 +232,7 @@ describe("getExponentialKeyboardConstraint", () => {
asymptote,
snapStep,
0,
range,
);

// Assert — skips y=1 (asymptote) and lands on y=0
Expand All @@ -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", () => {
Expand Down
Loading
Loading