diff --git a/.changeset/eight-stingrays-drum.md b/.changeset/eight-stingrays-drum.md new file mode 100644 index 00000000000..a34fdb3d8c1 --- /dev/null +++ b/.changeset/eight-stingrays-drum.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Add logarithm graph state management and reducer for supporting Logarithm graph in Interactive Graph diff --git a/packages/perseus/src/index.ts b/packages/perseus/src/index.ts index baaa129cf14..3a171aaf473 100644 --- a/packages/perseus/src/index.ts +++ b/packages/perseus/src/index.ts @@ -111,6 +111,7 @@ export { getAbsoluteValueCoords, getCircleCoords, getExponentialCoords, + getLogarithmCoords, getLineCoords, getLinearSystemCoords, getPointCoords, diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts index aeaf7e7570f..0306601876a 100644 --- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts @@ -355,6 +355,16 @@ class InteractiveGraphQuestionBuilder { return this; } + withLogarithm(options?: { + coords?: [Coord, Coord]; + asymptote?: number; + startCoords?: [Coord, Coord]; + startAsymptote?: number; + }): InteractiveGraphQuestionBuilder { + this.interactiveFigureConfig = new LogarithmGraphConfig(options); + return this; + } + withAbsoluteValue(options?: { coords?: [Coord, Coord]; startCoords?: [Coord, Coord]; @@ -864,6 +874,46 @@ class ExponentialGraphConfig implements InteractiveFigureConfig { } } +class LogarithmGraphConfig implements InteractiveFigureConfig { + private coords?: [Coord, Coord]; + private asymptote?: number; + private startCoords?: [Coord, Coord]; + private startAsymptote?: number; + + constructor(options?: { + coords?: [Coord, Coord]; + asymptote?: number; + startCoords?: [Coord, Coord]; + startAsymptote?: number; + }) { + this.coords = options?.coords; + this.asymptote = options?.asymptote; + this.startCoords = options?.startCoords; + this.startAsymptote = options?.startAsymptote; + } + + correct(): PerseusGraphType { + return { + type: "logarithm", + coords: this.coords, + asymptote: this.asymptote, + }; + } + + graph(): PerseusGraphType { + return { + type: "logarithm", + startCoords: + this.startCoords != null + ? { + coords: this.startCoords, + asymptote: this.startAsymptote ?? 0, + } + : undefined, + }; + } +} + class TangentGraphConfig implements InteractiveFigureConfig { private coords?: [Coord, Coord]; private startCoords?: [Coord, Coord]; 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 aa7bc118f36..11a3c59524d 100644 --- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx @@ -62,6 +62,8 @@ import { sinusoidQuestionWithDefaultCorrect, tangentQuestion, tangentQuestionWithDefaultCorrect, + logarithmQuestion, + logarithmQuestionWithDefaultCorrect, sinusoidWithPiTicks, unlimitedPointQuestion, unlimitedPolygonQuestion, @@ -239,6 +241,7 @@ describe("Interactive Graph", function () { quadratic: quadraticQuestion, sinusoid: sinusoidQuestion, tangent: tangentQuestion, + logarithm: logarithmQuestion, "unlimited-point": pointQuestion, "unlimited-polygon": polygonQuestion, }; @@ -257,6 +260,7 @@ describe("Interactive Graph", function () { quadratic: quadraticQuestionWithDefaultCorrect, sinusoid: sinusoidQuestionWithDefaultCorrect, tangent: tangentQuestionWithDefaultCorrect, + logarithm: logarithmQuestionWithDefaultCorrect, "unlimited-point": pointQuestionWithDefaultCorrect, "unlimited-polygon": polygonQuestionDefaultCorrect, }; diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.testdata.ts b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.testdata.ts index d93e8d19fde..31e983bb7df 100644 --- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.testdata.ts +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.testdata.ts @@ -825,6 +825,23 @@ export const graphWithLabeledFunction: PerseusRenderer = }) .build(); +export const logarithmQuestion: PerseusRenderer = + interactiveGraphQuestionBuilder() + .withContent( + "**Graph $f(x) = \\log(x + 6)$ in the interactive widget.**\n\n[[☃ interactive-graph 1]]", + ) + .withLogarithm({ + coords: [ + [-4, -3], + [-5, -7], + ], + asymptote: -6, + }) + .build(); + +export const logarithmQuestionWithDefaultCorrect: PerseusRenderer = + interactiveGraphQuestionBuilder().withLogarithm().build(); + export const sinusoidWithPiTicks: PerseusRenderer = interactiveGraphQuestionBuilder() .withXRange(-6 * Math.PI, 6 * Math.PI) diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx index 07374ba559f..0c425715946 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx @@ -777,6 +777,8 @@ const renderGraphElements = (props: { return renderAbsoluteValueGraph(state, dispatch, i18n); case "tangent": return renderTangentGraph(state, dispatch, i18n); + case "logarithm": + return {graph: null, interactiveElementsDescription: null}; default: throw new UnreachableCaseError(type); } diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-state-to-interactive-graph.test.ts b/packages/perseus/src/widgets/interactive-graphs/mafs-state-to-interactive-graph.test.ts index fd82189931c..51ec9a5d65b 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-state-to-interactive-graph.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-state-to-interactive-graph.test.ts @@ -14,6 +14,7 @@ import type { SegmentGraphState, SinusoidGraphState, TangentGraphState, + LogarithmGraphState, } from "./types"; import type {PerseusGraphType} from "@khanacademy/perseus-core"; @@ -491,4 +492,47 @@ describe("mafsStateToInteractiveGraph", () => { ], }); }); + + it("converts the state of a logarithm graph", () => { + const graph: PerseusGraphType = { + type: "logarithm", + startCoords: { + coords: [ + [5, 6], + [7, 8], + ], + asymptote: 3, + }, + }; + const state: LogarithmGraphState = { + ...commonGraphState, + type: "logarithm", + coords: [ + [1, 2], + [3, 4], + ], + asymptote: -1, + }; + + const result: PerseusGraphType = mafsStateToInteractiveGraph( + state, + graph, + ); + + expect(result).toEqual({ + type: "logarithm", + coords: [ + [1, 2], + [3, 4], + ], + asymptote: -1, + startCoords: { + coords: [ + [5, 6], + [7, 8], + ], + asymptote: 3, + }, + }); + }); }); diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-state-to-interactive-graph.ts b/packages/perseus/src/widgets/interactive-graphs/mafs-state-to-interactive-graph.ts index f09f78ab33f..b98e6cca8f7 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-state-to-interactive-graph.ts +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-state-to-interactive-graph.ts @@ -110,6 +110,13 @@ export function mafsStateToInteractiveGraph( ...originalGraph, coords: state.coords, }; + case "logarithm": + invariant(originalGraph.type === "logarithm"); + return { + ...originalGraph, + coords: state.coords, + asymptote: state.asymptote, + }; default: throw new UnreachableCaseError(state); } diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.test.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.test.ts index a2b5c5ab187..2d22af283a3 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.test.ts @@ -523,6 +523,72 @@ describe("initializeGraphState for exponential graphs", () => { }); }); +describe("initializeGraphState for logarithm graphs", () => { + it("uses the given coords and asymptote if present", () => { + // Arrange, Act + const graph = initializeGraphState({ + ...baseGraphData, + graph: { + type: "logarithm", + coords: [ + [-4, -3], + [-5, -7], + ], + asymptote: -6, + }, + }); + + // Assert + invariant(graph.type === "logarithm"); + expect(graph.coords).toEqual([ + [-4, -3], + [-5, -7], + ]); + expect(graph.asymptote).toBe(-6); + }); + + it("uses startCoords if given and explicit coords are absent", () => { + // Arrange, Act + const graph = initializeGraphState({ + ...baseGraphData, + graph: { + type: "logarithm", + startCoords: { + coords: [ + [1, 4], + [3, 8], + ], + asymptote: 2, + }, + }, + }); + + // Assert + invariant(graph.type === "logarithm"); + expect(graph.coords).toEqual([ + [1, 4], + [3, 8], + ]); + expect(graph.asymptote).toBe(2); + }); + + it("uses default coords and asymptote if neither coords nor startCoords are given", () => { + // Arrange, Act + const graph = initializeGraphState({ + ...baseGraphData, + graph: {type: "logarithm"}, + }); + + // Assert + invariant(graph.type === "logarithm"); + expect(graph.coords).toEqual([ + [1, 1], + [5, 5], + ]); + expect(graph.asymptote).toBe(0); + }); +}); + describe("initializeGraphState for tangent graphs", () => { it("uses the given coords, if present", () => { const graph = initializeGraphState({ diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts index 727595d43ee..1f888d7d111 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts @@ -21,6 +21,7 @@ import type { PerseusGraphTypeSinusoid, PerseusGraphTypeExponential, PerseusGraphTypeTangent, + PerseusGraphTypeLogarithm, } from "@khanacademy/perseus-core"; import type {Interval} from "mafs"; @@ -149,7 +150,8 @@ export function initializeGraphState( case "logarithm": return { ...shared, - type: "none", + type: graph.type, + ...getLogarithmCoords(graph, range, step), }; default: throw new UnreachableCaseError(graph); @@ -516,6 +518,37 @@ export function getExponentialCoords( return {coords, asymptote}; } +export function getLogarithmCoords( + graph: PerseusGraphTypeLogarithm, + range: [x: Interval, y: Interval], + step: [x: number, y: number], +): {coords: [Coord, Coord]; asymptote: number} { + if (graph.coords && graph.asymptote != null) { + return { + coords: [graph.coords[0], graph.coords[1]], + asymptote: graph.asymptote, + }; + } + + // Default coords as normalized fractions of the graph range. After + // normalization with the default asymptote at x=0, both points will + // be to the right of the asymptote. + let defaultCoords: [Coord, Coord] = [ + [0.55, 0.55], + [0.75, 0.75], + ]; + defaultCoords = normalizePoints(range, step, defaultCoords, true); + + const coords: [Coord, Coord] = graph.startCoords + ? graph.startCoords.coords + : defaultCoords; + const asymptote: number = graph.startCoords + ? graph.startCoords.asymptote + : 0; + + return {coords, asymptote}; +} + export const getAngleCoords = (params: { graph: PerseusGraphTypeAngle; range: [x: Interval, y: Interval]; diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-action.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-action.ts index 376b0fe1e0b..6099a163c13 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-action.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-action.ts @@ -83,6 +83,10 @@ export const actions = { movePoint, moveCenter, }, + logarithm: { + movePoint, + moveCenter, + }, absoluteValue: { movePoint, }, diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.test.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.test.ts index 84e5a65f5d1..92bfcb593fc 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.test.ts @@ -11,6 +11,7 @@ import type { InteractiveGraphState, PolygonGraphState, TangentGraphState, + LogarithmGraphState, } from "../types"; import type {GraphRange} from "@khanacademy/perseus-core"; @@ -1967,3 +1968,235 @@ describe("moveCenter on an exponential graph (asymptote)", () => { expect(updated.asymptote).toBe(-2); }); }); + +function generateLogarithmGraphState( + overrides?: Partial>, +): LogarithmGraphState { + return { + hasBeenInteractedWith: false, + type: "logarithm", + range: [ + [-10, 10], + [-10, 10], + ], + snapStep: [1, 1], + coords: [ + [-4, -3], + [-5, -7], + ], + asymptote: -6, + ...overrides, + }; +} + +describe("movePoint on a logarithm graph", () => { + it("rejects the move when both points would share the same y-coordinate", () => { + // Arrange — point 0 at y=-3, point 1 at y=-7; trying to move point 0 to y=-7 + const state = generateLogarithmGraphState(); + + // Act + const updated = interactiveGraphReducer( + state, + actions.logarithm.movePoint(0, [-4, -7]), + ); + + // Assert — move was rejected; point 0 stays at original position + invariant(updated.type === "logarithm"); + expect(updated.coords[0]).toEqual([-4, -3]); + }); + + it("rejects the move when bounding causes same-y collision", () => { + // Arrange — point 0 at (-4, -7), point 1 at (-5, 9); moving point 0 + // far beyond the graph range so bounding clamps y to 9, same as point 1 + const state = generateLogarithmGraphState({ + coords: [ + [-4, -7], + [-5, 9], + ], + }); + + // Act — destination y=15 is clamped to 9 by bounding, colliding with point 1 + const updated = interactiveGraphReducer( + state, + actions.logarithm.movePoint(0, [-4, 15]), + ); + + // Assert — rejected; point 0 stays at original position + invariant(updated.type === "logarithm"); + expect(updated.coords[0]).toEqual([-4, -7]); + expect(updated.hasBeenInteractedWith).toBe(false); + }); + + it("rejects the move when point would land on the asymptote", () => { + // Arrange — asymptote at x=-6; trying to move point 0 to x=-6 + const state = generateLogarithmGraphState(); + + // Act + const updated = interactiveGraphReducer( + state, + actions.logarithm.movePoint(0, [-6, -2]), + ); + + // Assert — move was rejected + invariant(updated.type === "logarithm"); + expect(updated.coords[0]).toEqual([-4, -3]); + expect(updated.hasBeenInteractedWith).toBe(false); + }); + + it("reflects the other point when a point crosses the asymptote", () => { + // Arrange — asymptote at x=-6, points at (-4, -3) and (-5, -7) + // Both points are to the right of the asymptote. + const state = generateLogarithmGraphState(); + + // Act — move point 0 to x=-8 (left of asymptote at x=-6) + const updated = interactiveGraphReducer( + state, + actions.logarithm.movePoint(0, [-8, -3]), + ); + + // Assert — point 0 moved, point 1 reflected across asymptote: + // reflectedX = 2 * (-6) - (-5) = -12 + 5 = -7 + invariant(updated.type === "logarithm"); + expect(updated.coords[0]).toEqual([-8, -3]); + expect(updated.coords[1]).toEqual([-7, -7]); + }); + + it("allows a valid move", () => { + // Arrange + const state = generateLogarithmGraphState(); + + // Act + const updated = interactiveGraphReducer( + state, + actions.logarithm.movePoint(0, [-3, -2]), + ); + + // Assert + invariant(updated.type === "logarithm"); + expect(updated.coords[0]).toEqual([-3, -2]); + }); + + it("rejects cross-asymptote move when reflection would cause same-x collision", () => { + // Arrange — asymptote=-6, coords=[(-4,-3), (-5,-7)], snapStep=[1,1] + // Moving point 0 to (-7,-3) crosses the asymptote. + // reflectedX = 2*(-6) - (-5) = -7, so both points would be at x=-7. + const state = generateLogarithmGraphState(); + + // Act + const updated = interactiveGraphReducer( + state, + actions.logarithm.movePoint(0, [-7, -3]), + ); + + // Assert — move was rejected; state is unchanged + invariant(updated.type === "logarithm"); + expect(updated.coords[0]).toEqual([-4, -3]); + expect(updated.coords[1]).toEqual([-5, -7]); + expect(updated.hasBeenInteractedWith).toBe(false); + }); + + it("rejects cross-asymptote move when reflected point snaps to the asymptote x-coordinate", () => { + // Arrange — asymptote=-6, non-grid-aligned point at x=-5.6. + // Moving point 0 to (-8,-3) crosses the asymptote. + // reflectedX = 2*(-6) - (-5.6) = -6.4, which snaps to -6 + // (the asymptote). This must be rejected. + const state = generateLogarithmGraphState({ + coords: [ + [-4, -3], + [-5.6, -7], + ], + }); + + // Act + const updated = interactiveGraphReducer( + state, + actions.logarithm.movePoint(0, [-8, -3]), + ); + + // Assert — move was rejected; state is unchanged + invariant(updated.type === "logarithm"); + expect(updated.coords[0]).toEqual([-4, -3]); + expect(updated.coords[1]).toEqual([-5.6, -7]); + expect(updated.hasBeenInteractedWith).toBe(false); + }); +}); + +describe("moveCenter on a logarithm graph (asymptote)", () => { + it("moves the asymptote to a new x-value", () => { + // Arrange + const state = generateLogarithmGraphState(); + + // Act + const updated = interactiveGraphReducer( + state, + actions.logarithm.moveCenter([-8, 99]), + ); + invariant(updated.type === "logarithm"); + + // Assert — x=-8 is to the left of both curve points (x=-4 and x=-5), so it's valid + expect(updated.asymptote).toBe(-8); + }); + + it("snaps past the curve points when the asymptote is dragged between them", () => { + // Arrange — curve points at x=-4 and x=-5; trying to move asymptote + // between them. boundAndSnapToGrid snaps destination to x=-4. + const state = generateLogarithmGraphState({ + coords: [ + [-4, -3], + [-5, -7], + ], + }); + + // Act — destination snaps to x=-4, which is on a point. Since + // -4 >= midpoint(-4.5), snap-through pushes to rightMost + step = -3. + const updated = interactiveGraphReducer( + state, + actions.logarithm.moveCenter([-4.4, 0]), + ); + invariant(updated.type === "logarithm"); + + // Assert + expect(updated.asymptote).toBe(-3); + }); + + it("ignores the y component and only moves the asymptote horizontally", () => { + // Arrange + const state = generateLogarithmGraphState(); + + // Act — pass an arbitrary y value; only x should matter + const updated = interactiveGraphReducer( + state, + actions.logarithm.moveCenter([-8, 99]), + ); + invariant(updated.type === "logarithm"); + + // Assert — asymptote moves to x=-8 regardless of the y passed + expect(updated.asymptote).toBe(-8); + }); + + it("rejects the move when asymptote would still be between the two points after snap-through", () => { + // Arrange — points at x=8 and x=10. When the asymptote is dragged + // to x=9 (between them), midpoint=(8+10)/2=9. Since 9 >= 9, + // snap-through pushes to rightMost + step = 10 + 1 = 11. Clamping + // to inset bounds [-9, 9] gives 9. The stillAllRight/stillAllLeft + // check rejects because 8 < 9 < 10 — the asymptote would still + // be between the two points. + const state = generateLogarithmGraphState({ + coords: [ + [8, -3], + [10, -7], + ], + asymptote: 0, + }); + + // Act + const updated = interactiveGraphReducer( + state, + actions.logarithm.moveCenter([9, 0]), + ); + invariant(updated.type === "logarithm"); + + // Assert — rejected; asymptote stays at 0 + expect(updated.asymptote).toBe(0); + }); +}); 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 762d2ee14f8..b24bb05716f 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 @@ -315,6 +315,7 @@ function doMovePointInFigure( case "absolute-value": case "tangent": case "exponential": + case "logarithm": throw new Error( `Don't use movePointInFigure for ${state.type} graphs. Use movePoint instead!`, ); @@ -587,6 +588,87 @@ function doMovePoint( [otherPoint[X], reflectedY], state, ); + + // Both points at the same x is invalid for an exponential + if (updatedCoords[0][X] === updatedCoords[1][X]) { + return state; + } + + return { + ...state, + hasBeenInteractedWith: true, + coords: updatedCoords, + }; + } + + return { + ...state, + hasBeenInteractedWith: true, + coords: setAtIndex({ + array: state.coords, + index: action.index, + newValue: boundDestination, + }), + }; + } + case "logarithm": { + const boundDestination = boundAndSnapToGrid( + action.destination, + state, + ); + const newCoords: vec.Vector2[] = [...state.coords]; + newCoords[action.index] = boundDestination; + + const asymptoteX = state.asymptote; + + // Point cannot land on the asymptote + if (boundDestination[X] === asymptoteX) { + return state; + } + + // Both points must have different x-values + // (same x makes the logarithm coefficient computation degenerate) + if (newCoords[0][X] === newCoords[1][X]) { + return state; + } + + // Both points must have different y-values + // (same y makes the logarithm coefficient computation degenerate) + if (newCoords[0][Y] === newCoords[1][Y]) { + return state; + } + + // If the moved point crosses the asymptote, reflect the other + // point across it so the entire curve moves to the new side. + const otherIndex = 1 - action.index; + const otherPoint = state.coords[otherIndex]; + const movedSide = boundDestination[X] > asymptoteX; + const otherSide = otherPoint[X] > asymptoteX; + + if (movedSide !== otherSide) { + const reflectedX = 2 * asymptoteX - otherPoint[X]; + const updatedCoords: [vec.Vector2, vec.Vector2] = [ + ...state.coords, + ]; + updatedCoords[action.index] = boundDestination; + updatedCoords[otherIndex] = boundAndSnapToGrid( + [reflectedX, otherPoint[Y]], + state, + ); + + // Reflected point cannot land on the asymptote + if (updatedCoords[otherIndex][X] === asymptoteX) { + return state; + } + + // Both points at the same x or y is invalid for a logarithm + if ( + updatedCoords[0][X] === updatedCoords[1][X] || + updatedCoords[0][Y] === updatedCoords[1][Y] + ) { + return state; + } + return { ...state, hasBeenInteractedWith: true, @@ -734,7 +816,6 @@ function doMoveCenter( let newY = boundAndSnapToGrid(action.destination, state)[Y]; const coords = state.coords; const stepY = state.snapStep[Y]; - const [, yRange] = state.range; // Both points must stay on the same side of the new asymptote position const allAbove = coords[0][Y] > newY && coords[1][Y] > newY; @@ -748,7 +829,21 @@ function doMoveCenter( const midpoint = (topMost + bottomMost) / 2; newY = newY >= midpoint ? topMost + stepY : bottomMost - stepY; - newY = clamp(newY, yRange[0], yRange[1]); + // Clamp to the snap-inset bounds (not raw range) so the + // asymptote can't be pushed to the graph edge where no + // point can be placed on the other side. + const insetY = inset(state.snapStep, state.range)[1]; + newY = clamp(newY, insetY[0], insetY[1]); + + // After clamping, the asymptote may have ended up back + // between or on the points. If so, reject the move. + const stillAllAbove = + coords[0][Y] > newY && coords[1][Y] > newY; + const stillAllBelow = + coords[0][Y] < newY && coords[1][Y] < newY; + if (!stillAllAbove && !stillAllBelow) { + return state; + } } // Final safety: asymptote must not land exactly on either point @@ -762,9 +857,49 @@ function doMoveCenter( asymptote: newY, }; } + case "logarithm": { + // Move the asymptote horizontally only + let newX = boundAndSnapToGrid(action.destination, state)[X]; + const coords = state.coords; + const stepX = state.snapStep[X]; + + // Both points must stay on the same side of the new asymptote position + const allRight = coords[0][X] > newX && coords[1][X] > newX; + const allLeft = coords[0][X] < newX && coords[1][X] < newX; + + if (!allRight && !allLeft) { + // Asymptote would end up between or on the points. + // Snap to whichever valid side the user is dragging toward. + const rightMost = Math.max(coords[0][X], coords[1][X]); + const leftMost = Math.min(coords[0][X], coords[1][X]); + const midpoint = (rightMost + leftMost) / 2; + + newX = newX >= midpoint ? rightMost + stepX : leftMost - stepX; + // Clamp to the snap-inset bounds (not raw range) so the + // asymptote can't be pushed to the graph edge where no + // point can be placed on the other side. + const insetX = inset(state.snapStep, state.range)[0]; + newX = clamp(newX, insetX[0], insetX[1]); + + // After clamping, the asymptote may have ended up back + // between or on the points. If so, reject the move. + const stillAllRight = + coords[0][X] > newX && coords[1][X] > newX; + const stillAllLeft = coords[0][X] < newX && coords[1][X] < newX; + if (!stillAllRight && !stillAllLeft) { + return state; + } + } + + return { + ...state, + hasBeenInteractedWith: true, + asymptote: newX, + }; + } default: throw new Error( - "The doMoveCenter action is only for circle or exponential graphs", + "The doMoveCenter action is only for circle, exponential, or logarithm graphs", ); } } diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.test.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.test.ts index 5a5046c02fb..3b6c38c2fd7 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.test.ts @@ -2,7 +2,7 @@ import invariant from "tiny-invariant"; import {getGradableGraph} from "./interactive-graph-state"; -import type {InteractiveGraphState} from "../types"; +import type {InteractiveGraphState, LogarithmGraphState} from "../types"; import type {PerseusGraphType} from "@khanacademy/perseus-core"; const defaultUnlimitedPointState: InteractiveGraphState = { @@ -118,4 +118,31 @@ describe("getGradableGraph", () => { invariant(result.type === "polygon"); expect(result.coords).toEqual([[5, 0]]); }); + + it("returns correct logarithm state with coords and asymptote", () => { + const state: LogarithmGraphState = { + type: "logarithm", + hasBeenInteractedWith: true, + range: [ + [-10, 10], + [-10, 10], + ], + snapStep: [1, 1], + coords: [ + [-4, -3], + [-5, -7], + ], + asymptote: -6, + }; + const initialGraph: PerseusGraphType = { + type: "logarithm", + }; + const result = getGradableGraph(state, initialGraph); + invariant(result.type === "logarithm"); + expect(result.coords).toEqual([ + [-4, -3], + [-5, -7], + ]); + expect(result.asymptote).toBe(-6); + }); }); diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.ts index bbd51edbc15..9bdd44860d6 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.ts @@ -100,6 +100,14 @@ export function getGradableGraph( }; } + if (state.type === "logarithm" && initialGraph.type === "logarithm") { + return { + ...initialGraph, + coords: state.coords, + asymptote: state.asymptote, + }; + } + if (state.type === "tangent" && initialGraph.type === "tangent") { return { ...initialGraph, diff --git a/packages/perseus/src/widgets/interactive-graphs/types.ts b/packages/perseus/src/widgets/interactive-graphs/types.ts index 3eec034675b..6ec5c13d752 100644 --- a/packages/perseus/src/widgets/interactive-graphs/types.ts +++ b/packages/perseus/src/widgets/interactive-graphs/types.ts @@ -42,7 +42,8 @@ export type InteractiveGraphState = | QuadraticGraphState | SinusoidGraphState | ExponentialGraphState - | TangentGraphState; + | TangentGraphState + | LogarithmGraphState; export type UnlimitedGraphState = PointGraphState | PolygonGraphState; @@ -137,6 +138,13 @@ export interface TangentGraphState extends InteractiveGraphStateCommon { coords: [vec.Vector2, vec.Vector2]; } +export interface LogarithmGraphState extends InteractiveGraphStateCommon { + type: "logarithm"; + coords: [vec.Vector2, vec.Vector2]; + /** The x-value of the vertical asymptote (x = asymptote). */ + asymptote: number; +} + export interface AngleGraphState extends InteractiveGraphStateCommon { type: "angle"; // Whether to show the angle measurements. default: false