Skip to content

Commit 3cc56f6

Browse files
authored
[Interactive Graph] Add logarithm graph rendering, SR strings, and equation string (#3423)
## Summary: PR series to add logarithm graph support to the Interactive Graph widget: 1. [Add logarithm graph type definitions and data](#3420) 2. [Add logarithm math utilities to kmath](#3421) 3. [Add logarithm graph state management and reducer](#3422) 4. ▶️ [Add logarithm graph rendering, SR strings, and equation string](#3423) 5. [Add logarithm graph scoring](#3424) 6. [Add logarithm graph option in the Interactive Graph Editor](#3425) Create the logarithm graph visual component, add Storybook coverage, SR strings, and equation string for supporting Logarithm graph in Interactive Graph - Create the logarithm graph visual component (`logarithm.tsx`) with curve rendering, draggable asymptote, and movable points - Add 6 screen reader strings for accessibility - Add equation string display for the editor - Add Storybook story ## Details This is the largest PR in the series. It creates the visual rendering of the logarithm graph, following the exponential component pattern with the axis swapped (vertical asymptote instead of horizontal). **Curve rendering:** - Single `<Plot.OfX>` with domain restricted to one side of the asymptote (`[asymptoteX + 0.001, xMax]` or `[xMin, asymptoteX - 0.001]`) - Plot function computes `a * ln(b*x + c)`, returning NaN when outside domain or when y-value exceeds visible range + padding (prevents curve visually touching the asymptote) - `coeffRef` caches last valid coefficients as fallback during transient invalid states **Asymptote rendering:** - Uses existing `MovableAsymptote` component with `orientation="vertical"` - Dispatches `actions.logarithm.moveCenter()` on drag - `constrainAsymptoteKeyboard()` implements snap-through logic for keyboard navigation (mirrors exponential's vertical version but on X-axis) **Keyboard constraints:** - `getLogarithmKeyboardConstraint()` prevents points from landing on the asymptote's x-coordinate or sharing y-value with the other point - Uses bounded retry (max 3 steps) to skip past invalid positions **Equation string:** - `getLogarithmEquationString()` displays `y = a*ln(b*x + c)` with computed coefficient values - `defaultLogarithmCoords()` provides fallback coords (normalized fractions `[0.55, 0.55]`, `[0.75, 0.75]`) **Screen reader strings (6):** - `srLogarithmGraph` — graph container label - `srLogarithmPoint1` / `srLogarithmPoint2` — point position labels - `srLogarithmAsymptote` — asymptote label with keyboard instructions - `srLogarithmDescription` — graph state description - `srLogarithmInteractiveElements` — interactive elements summary Co-Authored by Claude Code (Opus) Issue: LEMS-3953 ## Test plan: - [ ] `pnpm tsc` passes - [ ] `pnpm knip` passes - [ ] `pnpm lint` passes - [ ] `pnpm prettier . --check` passes - [ ] Logarithm component tests pass (15 new tests): - 9 screen reader tests (aria-labels, descriptions, interactive elements, updates on state change) - 3 keyboard constraint tests for points (valid move, skip asymptote, skip same-y) - 3 keyboard constraint tests for asymptote (free move, snap-through, skip point x-value) - [ ] Interactive graph tests pass (178 passed, 28 skipped) - [ ] Verify rendering in Storybook (logarithm story renders correctly, curve matches expected shape) Author: ivyolamit Reviewers: claude[bot], ivyolamit, SonicScrewdriver Required Reviewers: Approved By: SonicScrewdriver Checks: ⏭️ 1 check has been skipped, ✅ 10 checks were successful Pull Request URL: #3423
1 parent a4eaa5a commit 3cc56f6

14 files changed

Lines changed: 1168 additions & 79 deletions

.changeset/hip-turtles-sort.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/perseus": minor
3+
---
4+
5+
Create the logarithm graph visual component, add Storybook coverage, SR strings, and equation string for supporting Logarithm graph in Interactive Graph

packages/perseus/src/strings.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,36 @@ export type PerseusStrings = {
522522
asymptoteY: string;
523523
}) => string;
524524
srExponentialAsymptote: ({asymptoteY}: {asymptoteY: string}) => string;
525+
srLogarithmGraph: string;
526+
srLogarithmPoint1: ({x, y}: {x: string; y: string}) => string;
527+
srLogarithmPoint2: ({x, y}: {x: string; y: string}) => string;
528+
srLogarithmDescription: ({
529+
point1X,
530+
point1Y,
531+
point2X,
532+
point2Y,
533+
asymptoteX,
534+
}: {
535+
point1X: string;
536+
point1Y: string;
537+
point2X: string;
538+
point2Y: string;
539+
asymptoteX: string;
540+
}) => string;
541+
srLogarithmInteractiveElements: ({
542+
point1X,
543+
point1Y,
544+
point2X,
545+
point2Y,
546+
asymptoteX,
547+
}: {
548+
point1X: string;
549+
point1Y: string;
550+
point2X: string;
551+
point2Y: string;
552+
asymptoteX: string;
553+
}) => string;
554+
srLogarithmAsymptote: ({asymptoteX}: {asymptoteX: string}) => string;
525555
srAbsoluteValueGraph: string;
526556
srAbsoluteValueVertexPoint: ({x, y}: {x: string; y: string}) => string;
527557
srAbsoluteValueSecondPoint: ({x, y}: {x: string; y: string}) => string;
@@ -1202,6 +1232,39 @@ export const strings = {
12021232
message:
12031233
"Horizontal asymptote at y equals %(asymptoteY)s. Use up and down arrow keys to move.",
12041234
},
1235+
srLogarithmGraph: {
1236+
context:
1237+
"Aria label for the container containing a Logarithm function in the interactive graph widget.",
1238+
message: "A logarithm function on a coordinate plane.",
1239+
},
1240+
srLogarithmPoint1: {
1241+
context:
1242+
"Aria label for the first Point on the Logarithm function in the interactive graph widget.",
1243+
message: "Point 1 at %(x)s comma %(y)s.",
1244+
},
1245+
srLogarithmPoint2: {
1246+
context:
1247+
"Aria label for the second Point on the Logarithm function in the interactive graph widget.",
1248+
message: "Point 2 at %(x)s comma %(y)s.",
1249+
},
1250+
srLogarithmDescription: {
1251+
context:
1252+
"Screen reader description of the Logarithm function in the interactive graph widget.",
1253+
message:
1254+
"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.",
1255+
},
1256+
srLogarithmInteractiveElements: {
1257+
context:
1258+
"Screen reader description of all the elements available to interact with within the Logarithm function in the interactive graph widget.",
1259+
message:
1260+
"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.",
1261+
},
1262+
srLogarithmAsymptote: {
1263+
context:
1264+
"Aria label for the draggable vertical asymptote line in the Logarithm function in the interactive graph widget.",
1265+
message:
1266+
"Vertical asymptote at x equals %(asymptoteX)s. Use left and right arrow keys to move.",
1267+
},
12051268
srAbsoluteValueGraph: {
12061269
context:
12071270
"Aria label for the container containing an Absolute Value function in the interactive graph widget.",
@@ -1612,6 +1675,27 @@ export const mockStrings: PerseusStrings = {
16121675
`Exponential graph with point 1 at ${point1X} comma ${point1Y}, point 2 at ${point2X} comma ${point2Y}, and horizontal asymptote at y equals ${asymptoteY}.`,
16131676
srExponentialAsymptote: ({asymptoteY}) =>
16141677
`Horizontal asymptote at y equals ${asymptoteY}. Use up and down arrow keys to move.`,
1678+
srLogarithmGraph: "A logarithm function on a coordinate plane.",
1679+
srLogarithmPoint1: ({x, y}) => `Point 1 at ${x} comma ${y}.`,
1680+
srLogarithmPoint2: ({x, y}) => `Point 2 at ${x} comma ${y}.`,
1681+
srLogarithmDescription: ({
1682+
point1X,
1683+
point1Y,
1684+
point2X,
1685+
point2Y,
1686+
asymptoteX,
1687+
}) =>
1688+
`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}.`,
1689+
srLogarithmInteractiveElements: ({
1690+
point1X,
1691+
point1Y,
1692+
point2X,
1693+
point2Y,
1694+
asymptoteX,
1695+
}) =>
1696+
`Logarithm graph with point 1 at ${point1X} comma ${point1Y}, point 2 at ${point2X} comma ${point2Y}, and vertical asymptote at x equals ${asymptoteX}.`,
1697+
srLogarithmAsymptote: ({asymptoteX}) =>
1698+
`Vertical asymptote at x equals ${asymptoteX}. Use left and right arrow keys to move.`,
16151699
srAbsoluteValueGraph: "An absolute value function on a coordinate plane.",
16161700
srAbsoluteValueVertexPoint: ({x, y}) => `Vertex point at ${x} comma ${y}.`,
16171701
srAbsoluteValueSecondPoint: ({x, y}) => `Point on arm at ${x} comma ${y}.`,

packages/perseus/src/widgets/interactive-graphs/__docs__/interactive-graph.stories.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
segmentWithAllLockedLineVariations,
1818
segmentWithAllLockedRayVariations,
1919
exponentialQuestion,
20+
logarithmQuestion,
2021
sinusoidQuestion,
2122
tangentQuestion,
2223
segmentWithLockedEllipses,
@@ -117,6 +118,12 @@ export const Sinusoid: Story = {
117118
},
118119
};
119120

121+
export const Logarithm: Story = {
122+
args: {
123+
item: generateTestPerseusItem({question: logarithmQuestion}),
124+
},
125+
};
126+
120127
export const Tangent: Story = {
121128
args: {
122129
item: generateTestPerseusItem({question: tangentQuestion}),

packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-asymptote.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,32 @@ describe("MovableAsymptote", () => {
8383
);
8484
});
8585

86+
it("blurs the element when a mouse drag ends to clear the focus ring", () => {
87+
// Arrange — start with dragging: true
88+
useDraggable.mockReturnValue({dragging: true});
89+
const {rerender} = render(
90+
<Mafs width={200} height={200}>
91+
<MovableAsymptote {...defaultProps} />
92+
</Mafs>,
93+
);
94+
95+
const group = screen.getByTestId("movable-asymptote");
96+
// Simulate that the element received focus during drag
97+
group.focus();
98+
const blurSpy = jest.spyOn(group, "blur");
99+
100+
// Act — drag ends
101+
useDraggable.mockReturnValue({dragging: false});
102+
rerender(
103+
<Mafs width={200} height={200}>
104+
<MovableAsymptote {...defaultProps} />
105+
</Mafs>,
106+
);
107+
108+
// Assert — blur was called to remove the focus ring
109+
expect(blurSpy).toHaveBeenCalled();
110+
});
111+
86112
it("renders the same structure for vertical orientation", () => {
87113
// Arrange, Act
88114
render(

packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-asymptote.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ export function MovableAsymptote(props: Props) {
5656
constrainKeyboardMovement: constrainKeyboardMovement ?? ((p) => p),
5757
});
5858

59+
// When a mouse drag ends, blur the element so the focus ring doesn't
60+
// persist after the user releases the mouse and moves away.
61+
const wasDragging = React.useRef(false);
62+
React.useEffect(() => {
63+
if (wasDragging.current && !dragging) {
64+
groupRef.current?.blur();
65+
}
66+
wasDragging.current = dragging;
67+
}, [dragging]);
68+
5969
return (
6070
<g
6171
ref={groupRef}

packages/perseus/src/widgets/interactive-graphs/graphs/exponential.test.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ describe("getExponentialKeyboardConstraint", () => {
200200
];
201201
const asymptote = 1;
202202
const snapStep: vec.Vector2 = [1, 1];
203+
const range: [vec.Vector2, vec.Vector2] = [
204+
[-10, 10],
205+
[-10, 10],
206+
];
203207

204208
it("moves point up by one snap step when valid", () => {
205209
// Arrange, Act
@@ -208,6 +212,7 @@ describe("getExponentialKeyboardConstraint", () => {
208212
asymptote,
209213
snapStep,
210214
0,
215+
range,
211216
);
212217

213218
// Assert
@@ -227,6 +232,7 @@ describe("getExponentialKeyboardConstraint", () => {
227232
asymptote,
228233
snapStep,
229234
0,
235+
range,
230236
);
231237

232238
// Assert — skips y=1 (asymptote) and lands on y=0
@@ -246,11 +252,84 @@ describe("getExponentialKeyboardConstraint", () => {
246252
asymptote,
247253
snapStep,
248254
0,
255+
range,
249256
);
250257

251258
// Assert — skips x=2 and lands on x=3
252259
expect(constraint.right).toEqual([3, 3]);
253260
});
261+
262+
it("rejects positions where the clamped coord collides with the other point's x", () => {
263+
// Arrange — points at [9,3] and [8,6], asymptote at 1.
264+
// Moving point 0 right: x=10 clamps to 9 (inset max),
265+
// which equals otherPoint[X]=... wait, otherPoint is point 1.
266+
// Actually let's use: point 0 at [8,3], point 1 at [9,6].
267+
// Moving right: x=9 shares x with point 1 (skip).
268+
// x=10 clamps to 9 (same as point 1). All further clamp to 9.
269+
const edgeCoords: [vec.Vector2, vec.Vector2] = [
270+
[8, 3],
271+
[9, 6],
272+
];
273+
274+
// Act
275+
const constraint = getExponentialKeyboardConstraint(
276+
edgeCoords,
277+
asymptote,
278+
snapStep,
279+
0,
280+
range,
281+
);
282+
283+
// Assert — no valid right move, falls back to original position
284+
expect(constraint.right).toEqual([8, 3]);
285+
});
286+
287+
it("stays put when all upward positions would cause a clamped reflection collision", () => {
288+
// Arrange — asymptote at y=8, points at [3,7] and [1,4].
289+
// Moving point 0 up: y=8 is the asymptote (skip), y=9 crosses
290+
// the asymptote so reflectedY = 2*8-4 = 12, which clamps to 9
291+
// (yMax - snapStep). clampedReflectedY(9) === clampedY(9). Skip.
292+
// y=10 clamps to 9 too. No valid upward position.
293+
const edgeCoords: [vec.Vector2, vec.Vector2] = [
294+
[3, 7],
295+
[1, 4],
296+
];
297+
298+
// Act
299+
const constraint = getExponentialKeyboardConstraint(
300+
edgeCoords,
301+
8,
302+
snapStep,
303+
0,
304+
range,
305+
);
306+
307+
// Assert — no valid up move, falls back to original position
308+
expect(constraint.up).toEqual([3, 7]);
309+
});
310+
311+
it("skips positions where the clamped asymptote check catches out-of-bounds coord", () => {
312+
// Arrange — asymptote at y=9 (one step from edge).
313+
// Point 0 at [3,8], moving up: y=9 is asymptote (skip).
314+
// y=10 clamps to 9 which also equals asymptote. Skip.
315+
// No valid upward position.
316+
const edgeCoords: [vec.Vector2, vec.Vector2] = [
317+
[3, 8],
318+
[1, 6],
319+
];
320+
321+
// Act
322+
const constraint = getExponentialKeyboardConstraint(
323+
edgeCoords,
324+
9,
325+
snapStep,
326+
0,
327+
range,
328+
);
329+
330+
// Assert — no valid up move
331+
expect(constraint.up).toEqual([3, 8]);
332+
});
254333
});
255334

256335
describe("constrainAsymptoteKeyboard", () => {

0 commit comments

Comments
 (0)