Skip to content

Commit a4eaa5a

Browse files
authored
[Interactive Graph] Add logarithm graph state management and reducer (#3422)
## 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) Add logarithm graph state management and reducer for supporting Logarithm graph in Interactive Graph - Add `LogarithmGraphState` to the internal state type system - Wire up reducer actions (`movePoint` + `moveCenter`) with logarithm-specific constraints - Add graph state initialization with sensible defaults - Add test data fixtures and question builder support ## Details This PR adds the state management layer for logarithm graphs, following the exponential pattern throughout. Logarithm is the vertical-asymptote mirror of exponential's horizontal-asymptote design. **Action registration:** Reuses existing `movePoint` and `moveCenter` action creators (no new action types). The `actions` export object gets `logarithm: { movePoint, moveCenter }`, identical to exponential. **Reducer — `doMovePoint`:** - Point cannot land on the asymptote's x-coordinate - Both points must have different y-values (prevents degenerate coefficient computation) - Cross-asymptote reflection: when a point is dragged past the asymptote, the other point is reflected (`reflectedX = 2 * asymptoteX - otherX`) so both points end up on the same side — matches Grapher widget behavior **Reducer — `doMoveCenter`:** - Asymptote moves horizontally only (X component extracted, Y ignored) - Snap-through logic: when the new position would land between or on the curve points, snaps past all points using the midpoint heuristic for direction detection (prevents oscillation/flicker) - Final safety check: asymptote cannot land exactly on either point's x-coordinate **Initialization:** `getLogarithmCoords()` follows `getExponentialCoords()` pattern — returns `{coords, asymptote}`. Default coords use normalized fractions `[0.55, 0.55]` and `[0.75, 0.75]` to ensure both points are to the right of the default asymptote at x=0 after normalization (x=0.5 would land exactly on the asymptote). **Placeholders:** `mafs-graph.tsx` returns `{graph: null, interactiveElementsDescription: null}` for logarithm (replaced in PR 4). `mafs-state-to-interactive-graph.ts` has the real serialization (not a placeholder). Co-Authored by Claude Code (Opus) Issue: LEMS-3953 ## Test plan: - [ ] `pnpm tsc` passes - [ ] `pnpm knip` passes - [ ] `pnpm lint` passes - [ ] `pnpm prettier . --check` passes - [ ] Reducer tests pass (147 total, 14 new logarithm tests): - `movePoint`: same-y rejection, bounding-to-same-y rejection, valid move, on-asymptote rejection, cross-asymptote reflection - `moveCenter`: valid move, snap-through between points, Y-component ignored, final safety rejection - Initialization: given coords, startCoords, defaults - Gradable graph: logarithm state conversion - Serialization: logarithm state to interactive graph - [ ] Interactive graph tests pass (206 total, logarithm added to parameterized test maps) - [ ] Serialization tests pass (14 total) Author: ivyolamit Reviewers: claude[bot], ivyolamit, SonicScrewdriver Required Reviewers: Approved By: SonicScrewdriver Checks: ⏭️ 1 check has been skipped, ✅ 10 checks were successful, ⚪️ 1 check is neutral Pull Request URL: #3422
1 parent 9099a40 commit a4eaa5a

16 files changed

Lines changed: 650 additions & 6 deletions

.changeset/eight-stingrays-drum.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+
Add logarithm graph state management and reducer for supporting Logarithm graph in Interactive Graph

packages/perseus/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export {
111111
getAbsoluteValueCoords,
112112
getCircleCoords,
113113
getExponentialCoords,
114+
getLogarithmCoords,
114115
getLineCoords,
115116
getLinearSystemCoords,
116117
getPointCoords,

packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,16 @@ class InteractiveGraphQuestionBuilder {
355355
return this;
356356
}
357357

358+
withLogarithm(options?: {
359+
coords?: [Coord, Coord];
360+
asymptote?: number;
361+
startCoords?: [Coord, Coord];
362+
startAsymptote?: number;
363+
}): InteractiveGraphQuestionBuilder {
364+
this.interactiveFigureConfig = new LogarithmGraphConfig(options);
365+
return this;
366+
}
367+
358368
withAbsoluteValue(options?: {
359369
coords?: [Coord, Coord];
360370
startCoords?: [Coord, Coord];
@@ -864,6 +874,46 @@ class ExponentialGraphConfig implements InteractiveFigureConfig {
864874
}
865875
}
866876

877+
class LogarithmGraphConfig implements InteractiveFigureConfig {
878+
private coords?: [Coord, Coord];
879+
private asymptote?: number;
880+
private startCoords?: [Coord, Coord];
881+
private startAsymptote?: number;
882+
883+
constructor(options?: {
884+
coords?: [Coord, Coord];
885+
asymptote?: number;
886+
startCoords?: [Coord, Coord];
887+
startAsymptote?: number;
888+
}) {
889+
this.coords = options?.coords;
890+
this.asymptote = options?.asymptote;
891+
this.startCoords = options?.startCoords;
892+
this.startAsymptote = options?.startAsymptote;
893+
}
894+
895+
correct(): PerseusGraphType {
896+
return {
897+
type: "logarithm",
898+
coords: this.coords,
899+
asymptote: this.asymptote,
900+
};
901+
}
902+
903+
graph(): PerseusGraphType {
904+
return {
905+
type: "logarithm",
906+
startCoords:
907+
this.startCoords != null
908+
? {
909+
coords: this.startCoords,
910+
asymptote: this.startAsymptote ?? 0,
911+
}
912+
: undefined,
913+
};
914+
}
915+
}
916+
867917
class TangentGraphConfig implements InteractiveFigureConfig {
868918
private coords?: [Coord, Coord];
869919
private startCoords?: [Coord, Coord];

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ import {
6262
sinusoidQuestionWithDefaultCorrect,
6363
tangentQuestion,
6464
tangentQuestionWithDefaultCorrect,
65+
logarithmQuestion,
66+
logarithmQuestionWithDefaultCorrect,
6567
sinusoidWithPiTicks,
6668
unlimitedPointQuestion,
6769
unlimitedPolygonQuestion,
@@ -239,6 +241,7 @@ describe("Interactive Graph", function () {
239241
quadratic: quadraticQuestion,
240242
sinusoid: sinusoidQuestion,
241243
tangent: tangentQuestion,
244+
logarithm: logarithmQuestion,
242245
"unlimited-point": pointQuestion,
243246
"unlimited-polygon": polygonQuestion,
244247
};
@@ -257,6 +260,7 @@ describe("Interactive Graph", function () {
257260
quadratic: quadraticQuestionWithDefaultCorrect,
258261
sinusoid: sinusoidQuestionWithDefaultCorrect,
259262
tangent: tangentQuestionWithDefaultCorrect,
263+
logarithm: logarithmQuestionWithDefaultCorrect,
260264
"unlimited-point": pointQuestionWithDefaultCorrect,
261265
"unlimited-polygon": polygonQuestionDefaultCorrect,
262266
};

packages/perseus/src/widgets/interactive-graphs/interactive-graph.testdata.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,23 @@ export const graphWithLabeledFunction: PerseusRenderer =
825825
})
826826
.build();
827827

828+
export const logarithmQuestion: PerseusRenderer =
829+
interactiveGraphQuestionBuilder()
830+
.withContent(
831+
"**Graph $f(x) = \\log(x + 6)$ in the interactive widget.**\n\n[[☃ interactive-graph 1]]",
832+
)
833+
.withLogarithm({
834+
coords: [
835+
[-4, -3],
836+
[-5, -7],
837+
],
838+
asymptote: -6,
839+
})
840+
.build();
841+
842+
export const logarithmQuestionWithDefaultCorrect: PerseusRenderer =
843+
interactiveGraphQuestionBuilder().withLogarithm().build();
844+
828845
export const sinusoidWithPiTicks: PerseusRenderer =
829846
interactiveGraphQuestionBuilder()
830847
.withXRange(-6 * Math.PI, 6 * Math.PI)

packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,8 @@ const renderGraphElements = (props: {
777777
return renderAbsoluteValueGraph(state, dispatch, i18n);
778778
case "tangent":
779779
return renderTangentGraph(state, dispatch, i18n);
780+
case "logarithm":
781+
return {graph: null, interactiveElementsDescription: null};
780782
default:
781783
throw new UnreachableCaseError(type);
782784
}

packages/perseus/src/widgets/interactive-graphs/mafs-state-to-interactive-graph.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
SegmentGraphState,
1515
SinusoidGraphState,
1616
TangentGraphState,
17+
LogarithmGraphState,
1718
} from "./types";
1819
import type {PerseusGraphType} from "@khanacademy/perseus-core";
1920

@@ -491,4 +492,47 @@ describe("mafsStateToInteractiveGraph", () => {
491492
],
492493
});
493494
});
495+
496+
it("converts the state of a logarithm graph", () => {
497+
const graph: PerseusGraphType = {
498+
type: "logarithm",
499+
startCoords: {
500+
coords: [
501+
[5, 6],
502+
[7, 8],
503+
],
504+
asymptote: 3,
505+
},
506+
};
507+
const state: LogarithmGraphState = {
508+
...commonGraphState,
509+
type: "logarithm",
510+
coords: [
511+
[1, 2],
512+
[3, 4],
513+
],
514+
asymptote: -1,
515+
};
516+
517+
const result: PerseusGraphType = mafsStateToInteractiveGraph(
518+
state,
519+
graph,
520+
);
521+
522+
expect(result).toEqual({
523+
type: "logarithm",
524+
coords: [
525+
[1, 2],
526+
[3, 4],
527+
],
528+
asymptote: -1,
529+
startCoords: {
530+
coords: [
531+
[5, 6],
532+
[7, 8],
533+
],
534+
asymptote: 3,
535+
},
536+
});
537+
});
494538
});

packages/perseus/src/widgets/interactive-graphs/mafs-state-to-interactive-graph.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ export function mafsStateToInteractiveGraph(
110110
...originalGraph,
111111
coords: state.coords,
112112
};
113+
case "logarithm":
114+
invariant(originalGraph.type === "logarithm");
115+
return {
116+
...originalGraph,
117+
coords: state.coords,
118+
asymptote: state.asymptote,
119+
};
113120
default:
114121
throw new UnreachableCaseError(state);
115122
}

packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,72 @@ describe("initializeGraphState for exponential graphs", () => {
523523
});
524524
});
525525

526+
describe("initializeGraphState for logarithm graphs", () => {
527+
it("uses the given coords and asymptote if present", () => {
528+
// Arrange, Act
529+
const graph = initializeGraphState({
530+
...baseGraphData,
531+
graph: {
532+
type: "logarithm",
533+
coords: [
534+
[-4, -3],
535+
[-5, -7],
536+
],
537+
asymptote: -6,
538+
},
539+
});
540+
541+
// Assert
542+
invariant(graph.type === "logarithm");
543+
expect(graph.coords).toEqual([
544+
[-4, -3],
545+
[-5, -7],
546+
]);
547+
expect(graph.asymptote).toBe(-6);
548+
});
549+
550+
it("uses startCoords if given and explicit coords are absent", () => {
551+
// Arrange, Act
552+
const graph = initializeGraphState({
553+
...baseGraphData,
554+
graph: {
555+
type: "logarithm",
556+
startCoords: {
557+
coords: [
558+
[1, 4],
559+
[3, 8],
560+
],
561+
asymptote: 2,
562+
},
563+
},
564+
});
565+
566+
// Assert
567+
invariant(graph.type === "logarithm");
568+
expect(graph.coords).toEqual([
569+
[1, 4],
570+
[3, 8],
571+
]);
572+
expect(graph.asymptote).toBe(2);
573+
});
574+
575+
it("uses default coords and asymptote if neither coords nor startCoords are given", () => {
576+
// Arrange, Act
577+
const graph = initializeGraphState({
578+
...baseGraphData,
579+
graph: {type: "logarithm"},
580+
});
581+
582+
// Assert
583+
invariant(graph.type === "logarithm");
584+
expect(graph.coords).toEqual([
585+
[1, 1],
586+
[5, 5],
587+
]);
588+
expect(graph.asymptote).toBe(0);
589+
});
590+
});
591+
526592
describe("initializeGraphState for tangent graphs", () => {
527593
it("uses the given coords, if present", () => {
528594
const graph = initializeGraphState({

packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
PerseusGraphTypeSinusoid,
2222
PerseusGraphTypeExponential,
2323
PerseusGraphTypeTangent,
24+
PerseusGraphTypeLogarithm,
2425
} from "@khanacademy/perseus-core";
2526
import type {Interval} from "mafs";
2627

@@ -149,7 +150,8 @@ export function initializeGraphState(
149150
case "logarithm":
150151
return {
151152
...shared,
152-
type: "none",
153+
type: graph.type,
154+
...getLogarithmCoords(graph, range, step),
153155
};
154156
default:
155157
throw new UnreachableCaseError(graph);
@@ -516,6 +518,37 @@ export function getExponentialCoords(
516518
return {coords, asymptote};
517519
}
518520

521+
export function getLogarithmCoords(
522+
graph: PerseusGraphTypeLogarithm,
523+
range: [x: Interval, y: Interval],
524+
step: [x: number, y: number],
525+
): {coords: [Coord, Coord]; asymptote: number} {
526+
if (graph.coords && graph.asymptote != null) {
527+
return {
528+
coords: [graph.coords[0], graph.coords[1]],
529+
asymptote: graph.asymptote,
530+
};
531+
}
532+
533+
// Default coords as normalized fractions of the graph range. After
534+
// normalization with the default asymptote at x=0, both points will
535+
// be to the right of the asymptote.
536+
let defaultCoords: [Coord, Coord] = [
537+
[0.55, 0.55],
538+
[0.75, 0.75],
539+
];
540+
defaultCoords = normalizePoints(range, step, defaultCoords, true);
541+
542+
const coords: [Coord, Coord] = graph.startCoords
543+
? graph.startCoords.coords
544+
: defaultCoords;
545+
const asymptote: number = graph.startCoords
546+
? graph.startCoords.asymptote
547+
: 0;
548+
549+
return {coords, asymptote};
550+
}
551+
519552
export const getAngleCoords = (params: {
520553
graph: PerseusGraphTypeAngle;
521554
range: [x: Interval, y: Interval];

0 commit comments

Comments
 (0)