diff --git a/.changeset/famous-ears-mix.md b/.changeset/famous-ears-mix.md new file mode 100644 index 00000000000..ff3b834e070 --- /dev/null +++ b/.changeset/famous-ears-mix.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": minor +"@khanacademy/perseus-editor": minor +--- + +Add logarithm graph option in the Interactive Graph Editor diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/components/graph-type-selector.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/components/graph-type-selector.tsx index bbb8324530b..610c90fbe84 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/components/graph-type-selector.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/components/graph-type-selector.tsx @@ -29,6 +29,11 @@ const GraphTypeSelector = (props: GraphTypeSelectorProps) => { "interactive-graph-tangent", ); + const showLogarithm = isFeatureOn( + {apiOptions: props.apiOptions}, + "interactive-graph-logarithm", + ); + return ( { {showTangent && ( )} + {showLogarithm && ( + + )} diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx index 2ca971f3833..a9345a86a7b 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx @@ -285,6 +285,25 @@ class InteractiveGraphEditor extends React.Component { } } + // Logarithm: the start asymptote must not fall between or on the + // curve's start points — that configuration produces an invalid + // logarithm (the coefficient formula requires all points to be + // strictly on one side of the asymptote). + if ( + this.props.graph?.type === "logarithm" && + this.props.graph.startCoords != null + ) { + const {coords, asymptote} = this.props.graph.startCoords; + const asymptoteX = asymptote; + const minX = Math.min(coords[0][0], coords[1][0]); + const maxX = Math.max(coords[0][0], coords[1][0]); + if (asymptoteX >= minX && asymptoteX <= maxX) { + issues.push( + "The logarithm start asymptote must not fall between or on the curve's start points.", + ); + } + } + return issues; }; diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/start-coords-logarithm.module.css b/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/start-coords-logarithm.module.css new file mode 100644 index 00000000000..8821417a1fc --- /dev/null +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/start-coords-logarithm.module.css @@ -0,0 +1,36 @@ +/* We have to use !important until wonder blocks is in the shared layer. */ +/* TODO(LEMS-3686): Remove the !important once we don't need it anymore. */ +.tile { + background-color: var( + --wb-semanticColor-core-background-instructive-subtle + ) !important; + margin-top: var(--wb-sizing-size_080) !important; + padding: var(--wb-sizing-size_120) !important; + border-radius: var(--wb-sizing-size_080) !important; +} + +.row { + display: flex !important; + flex-direction: row !important; + align-items: center !important; +} + +.textFieldWrapper { + width: var(--wb-sizing-size_800) !important; +} + +.equationSection { + margin-top: var(--wb-sizing-size_120) !important; +} + +.equationBody { + background-color: var( + --wb-semanticColor-core-background-neutral-subtle + ) !important; + border: var(--wb-border-width-thin) solid + var(--wb-semanticColor-core-border-neutral-subtle) !important; + margin-top: var(--wb-sizing-size_080) !important; + padding-left: var(--wb-sizing-size_080) !important; + padding-right: var(--wb-sizing-size_080) !important; + font-size: var(--wb-font-size-xSmall) !important; +} diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/start-coords-logarithm.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/start-coords-logarithm.tsx new file mode 100644 index 00000000000..03ca2d6f52b --- /dev/null +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/start-coords-logarithm.tsx @@ -0,0 +1,118 @@ +import {View} from "@khanacademy/wonder-blocks-core"; +import {Strut} from "@khanacademy/wonder-blocks-layout"; +import {spacing} from "@khanacademy/wonder-blocks-tokens"; +import {BodyMonospace, BodyText} from "@khanacademy/wonder-blocks-typography"; +import * as React from "react"; + +import CoordinatePairInput from "../../../components/coordinate-pair-input"; +import ScrolllessNumberTextField from "../../../components/scrollless-number-text-field"; + +import styles from "./start-coords-logarithm.module.css"; +import {getLogarithmEquation} from "./util"; + +import type {Coord} from "@khanacademy/perseus"; + +type LogarithmStartCoords = { + coords: [Coord, Coord]; + asymptote: number; +}; + +type Props = { + startCoords: LogarithmStartCoords; + onChange: (startCoords: LogarithmStartCoords) => void; +}; + +const StartCoordsLogarithm = (props: Props) => { + const {startCoords, onChange} = props; + const {coords, asymptote} = startCoords; + + // Local state for the asymptote x-value text field so the user can type + // freely without the field resetting mid-keystroke. Pattern from StartCoordsCircle. + const [asymptoteXState, setAsymptoteXState] = React.useState( + asymptote.toString(), + ); + + // Sync local state when props change (e.g. "Use default start coordinates" button). + React.useEffect(() => { + setAsymptoteXState(asymptote.toString()); + }, [asymptote]); + + function handleAsymptoteXChange(newValue: string) { + // Update the local state to update the input field immediately. + setAsymptoteXState(newValue); + + // Assume the user is still typing. Don't update props until a valid number. + if (isNaN(+newValue) || newValue === "") { + return; + } + + // Update the props (updates the graph). + const newX = parseFloat(newValue); + // Spread coords into a new array so that startCoords always gets a + // new reference. StatefulMafsGraph's useEffect only watches startCoords, + // so a new reference is required to trigger reinitialization even when + // only the asymptote changes. (Same reason circle creates a new + // {center, radius} object on every onChange call.) + onChange({ + coords: [coords[0], coords[1]], + asymptote: newX, + }); + } + + return ( + <> + {/* Current equation */} + + Starting equation: + + {getLogarithmEquation(coords, asymptote)} + + + + {/* Points UI */} + + {/* Point 1 */} + + Point 1: + + + onChange({coords: [value, coords[1]], asymptote}) + } + /> + + + + {/* Point 2 */} + + Point 2: + + + onChange({coords: [coords[0], value], asymptote}) + } + /> + + + + {/* Asymptote x-value — single number, mirroring radius in StartCoordsCircle */} + + Asymptote x = + + + + + + + + ); +}; + +export default StartCoordsLogarithm; diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/start-coords-settings.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/start-coords-settings.tsx index d4a6e57c81b..b214dffcab5 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/start-coords-settings.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/start-coords-settings.tsx @@ -25,6 +25,7 @@ import StartCoordsAngle from "./start-coords-angle"; import StartCoordsCircle from "./start-coords-circle"; import StartCoordsExponential from "./start-coords-exponential"; import StartCoordsLine from "./start-coords-line"; +import StartCoordsLogarithm from "./start-coords-logarithm"; import StartCoordsMultiline from "./start-coords-multiline"; import StartCoordsPoint from "./start-coords-point"; import StartCoordsQuadratic from "./start-coords-quadratic"; @@ -124,6 +125,21 @@ const StartCoordsSettingsInner = (props: Props) => { /> ); } + case "logarithm": { + const defaultLogarithmCoords = getDefaultGraphStartCoords( + props, + range, + step, + ) as {coords: [Coord, Coord]; asymptote: number}; + const currentLogarithmCoords = + props.startCoords ?? defaultLogarithmCoords; + return ( + + ); + } case "tangent": const tangentCoords = getTangentCoords(props, range, step); return ( diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/types.ts b/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/types.ts index 144613b0c59..4b933f3aaac 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/types.ts +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/types.ts @@ -13,6 +13,7 @@ type GraphTypesThatHaveStartCoords = | {type: "segment"} | {type: "sinusoid"} | {type: "exponential"} + | {type: "logarithm"} | {type: "tangent"}; export type StartCoords = Extract< diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/util.ts b/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/util.ts index 0b9bfec005e..c546efbd245 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/util.ts +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/util.ts @@ -4,6 +4,7 @@ import { getAngleCoords, getCircleCoords, getExponentialCoords, + getLogarithmCoords, getLineCoords, getLinearSystemCoords, getPointCoords, @@ -111,8 +112,14 @@ export function getDefaultGraphStartCoords( range, step, }); - case "logarithm": - return undefined; + case "logarithm": { + const {coords, asymptote} = getLogarithmCoords( + {...graph, startCoords: undefined}, + range, + step, + ); + return {coords, asymptote}; + } default: return undefined; } @@ -202,6 +209,49 @@ export const getQuadraticEquation = (startCoords: [Coord, Coord, Coord]) => { ); }; +export const getLogarithmEquation = ( + coords: [Coord, Coord], + asymptote: number, +) => { + const p1 = coords[0]; + const p2 = coords[1]; + + // Guard: same y, point on asymptote, or points on opposite sides + if (p1[1] === p2[1]) { + return "undefined (points share the same y)"; + } + if (p1[0] === asymptote || p2[0] === asymptote) { + return "undefined (point on asymptote)"; + } + + const ratio = (p1[0] - asymptote) / (p2[0] - asymptote); + if (ratio <= 0) { + return "undefined (points on opposite sides of asymptote)"; + } + + // Inverse exponential approach (mirrors getLogarithmCoefficients in kmath) + const bExp = Math.log(ratio) / (p1[1] - p2[1]); + const aExp = (p1[0] - asymptote) / Math.exp(bExp * p1[1]); + + if (!isFinite(bExp) || !isFinite(aExp) || aExp === 0 || bExp === 0) { + return "undefined (invalid coefficients)"; + } + + const a = 1 / bExp; + const b = 1 / aExp; + const c = -asymptote / aExp; + + return ( + "y = " + + a.toFixed(3) + + "ln(" + + b.toFixed(3) + + "x + " + + c.toFixed(3) + + ")" + ); +}; + export const getAngleEquation = ( startCoords: [Coord, Coord, Coord], allowReflexAngles: boolean = false, @@ -249,9 +299,8 @@ export const shouldShowStartCoordsUI = ( case "segment": case "sinusoid": case "absolute-value": - return true; case "logarithm": - return false; + return true; default: throw new UnreachableCaseError(graph); }