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
6 changes: 6 additions & 0 deletions .changeset/famous-ears-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@khanacademy/perseus": minor
"@khanacademy/perseus-editor": minor
---

Add logarithm graph option in the Interactive Graph Editor
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ const GraphTypeSelector = (props: GraphTypeSelectorProps) => {
"interactive-graph-tangent",
);

const showLogarithm = isFeatureOn(
{apiOptions: props.apiOptions},
"interactive-graph-logarithm",
);

return (
<SingleSelect
selectedValue={props.graphType}
Expand All @@ -49,6 +54,9 @@ const GraphTypeSelector = (props: GraphTypeSelectorProps) => {
{showTangent && (
<OptionItem value="tangent" label="Tangent function" />
)}
{showLogarithm && (
<OptionItem value="logarithm" label="Logarithm function" />
)}
<OptionItem value="circle" label="Circle" />
<OptionItem value="point" label="Point(s)" />
<OptionItem value="linear-system" label="Linear System" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,25 @@ class InteractiveGraphEditor extends React.Component<Props> {
}
}

// 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;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* We have to use !important until wonder blocks is in the shared layer. */
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.

I honestly wonder how many of these !importants are necessary. I would love it if we made a task to clean up as many of these as possible as part of our project (aka before LEMS-3686, which might be a while).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

for now it's necessary in editor side. But I agree let's revisit this before our project ends if that ticket linked is something we can pickup as cleanup task.

/* 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;
}
Original file line number Diff line number Diff line change
@@ -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.
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.

The AI really likes to say where it stole things from, but I'm not sure "Pattern from StartCoordsCircle." is particularly helpful

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 */}
<View className={styles.equationSection}>
<BodyText>Starting equation:</BodyText>
<BodyMonospace className={styles.equationBody}>
{getLogarithmEquation(coords, asymptote)}
</BodyMonospace>
</View>

{/* Points UI */}
<View className={styles.tile}>
{/* Point 1 */}
<View className={styles.row}>
<BodyText weight="bold">Point 1:</BodyText>
<Strut size={spacing.small_12} />
<CoordinatePairInput
coord={coords[0]}
labels={["x", "y"]}
onChange={(value) =>
onChange({coords: [value, coords[1]], asymptote})
}
/>
</View>
<Strut size={spacing.small_12} />

{/* Point 2 */}
<View className={styles.row}>
<BodyText weight="bold">Point 2:</BodyText>
<Strut size={spacing.small_12} />
<CoordinatePairInput
coord={coords[1]}
labels={["x", "y"]}
onChange={(value) =>
onChange({coords: [coords[0], value], asymptote})
}
/>
</View>
<Strut size={spacing.small_12} />

{/* Asymptote x-value — single number, mirroring radius in StartCoordsCircle */}
<BodyText weight="bold" tag="label" className={styles.row}>
Asymptote x =
<Strut size={spacing.small_12} />
<View className={styles.textFieldWrapper}>
<ScrolllessNumberTextField
value={asymptoteXState}
onChange={handleAsymptoteXChange}
/>
</View>
</BodyText>
</View>
</>
);
};

export default StartCoordsLogarithm;
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<StartCoordsLogarithm
startCoords={currentLogarithmCoords}
onChange={onChange}
/>
);
}
case "tangent":
const tangentCoords = getTangentCoords(props, range, step);
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type GraphTypesThatHaveStartCoords =
| {type: "segment"}
| {type: "sinusoid"}
| {type: "exponential"}
| {type: "logarithm"}
| {type: "tangent"};

export type StartCoords = Extract<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getAngleCoords,
getCircleCoords,
getExponentialCoords,
getLogarithmCoords,
getLineCoords,
getLinearSystemCoords,
getPointCoords,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down
Loading