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/rich-toys-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@khanacademy/perseus": minor
"@khanacademy/perseus-editor": minor
---

Add tangent graph option in the Interactive Graph Editor
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import {isFeatureOn} from "@khanacademy/perseus-core";
import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown";
import {StyleSheet} from "aphrodite";
import * as React from "react";

import type {APIOptions} from "@khanacademy/perseus";

type GraphTypeSelectorProps = {
graphType: string;
onChange: (newGraphType: string) => void;
apiOptions?: APIOptions;
};

const GraphTypeSelector = (props: GraphTypeSelectorProps) => {
const showTangent = isFeatureOn(
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.

Nice

{apiOptions: props.apiOptions},
"interactive-graph-tangent",
);

return (
<SingleSelect
selectedValue={props.graphType}
Expand All @@ -19,6 +28,9 @@ const GraphTypeSelector = (props: GraphTypeSelectorProps) => {
<OptionItem value="linear" label="Linear function" />
<OptionItem value="quadratic" label="Quadratic function" />
<OptionItem value="sinusoid" label="Sinusoid function" />
{showTangent && (
<OptionItem value="tangent" label="Tangent 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 @@ -369,6 +369,7 @@ class InteractiveGraphEditor extends React.Component<Props> {
this.props.graph?.type ??
InteractiveGraph.defaultProps.userInput.type
}
apiOptions={this.props.apiOptions}
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.

No action needed: I wish we had a context provider for APIOptions so we didn't have to pipe it through like this...

// TODO(LEMS-2656): remove TS suppression
onChange={
((
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getQuadraticCoords,
getSegmentCoords,
getSinusoidCoords,
getTangentCoords,
} from "@khanacademy/perseus";
import Button from "@khanacademy/wonder-blocks-button";
import {View} from "@khanacademy/wonder-blocks-core";
Expand All @@ -26,6 +27,7 @@ import StartCoordsMultiline from "./start-coords-multiline";
import StartCoordsPoint from "./start-coords-point";
import StartCoordsQuadratic from "./start-coords-quadratic";
import StartCoordsSinusoid from "./start-coords-sinusoid";
import StartCoordsTangent from "./start-coords-tangent";
import {getDefaultGraphStartCoords} from "./util";

import type {StartCoords} from "./types";
Expand Down Expand Up @@ -89,6 +91,14 @@ const StartCoordsSettingsInner = (props: Props) => {
onChange={onChange}
/>
);
case "tangent":
const tangentCoords = getTangentCoords(props, range, step);
return (
<StartCoordsTangent
startCoords={tangentCoords}
onChange={onChange}
/>
);
Comment thread
ivyolamit marked this conversation as resolved.
case "quadratic":
const quadraticCoords = getQuadraticCoords(props, range, step);
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* 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 {
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 don't understand why we need this file. It doesn't seem like any other chart type has a file like this.

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.

This would align to us using classNames in our styling. This is the effort that @mark-fitzgerald has been leading since last year.

display: flex !important;
flex-direction: row !important;
align-items: center !important;
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;
}

.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,61 @@
import {View} from "@khanacademy/wonder-blocks-core";
import {Strut} from "@khanacademy/wonder-blocks-layout";
import {spacing} from "@khanacademy/wonder-blocks-tokens";
import {
BodyMonospace,
LabelLarge,
LabelMedium,
} from "@khanacademy/wonder-blocks-typography";
import * as React from "react";

import CoordinatePairInput from "../../../components/coordinate-pair-input";

import styles from "./start-coords-tangent.module.css";
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.

This feels wrong to me - adding styles specifically for the tangent editor. I think we need to rethink this so that we're being consistent across all graph types.

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.

Personally, I'm okay with this as I know we've been specifically trying to move to css modules. It makes sense to me that we are breaking the pattern here as we simply haven't updated the old files yet — but I think updating the old files could be a perfect addition to our Interactive Graph: Phase 2 work.

Perhaps @mark-fitzgerald has thoughts as well.

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.

good call @SonicScrewdriver i'll add a ticket for that.

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.

import {getTangentEquation} from "./util";

import type {Coord} from "@khanacademy/perseus";

type TangentCoords = [Coord, Coord];

type Props = {
startCoords: TangentCoords;
onChange: (startCoords: TangentCoords) => void;
};

const StartCoordsTangent = (props: Props) => {
const {startCoords, onChange} = props;

return (
<>
{/* Current equation */}
<View className={styles.equationSection}>
<LabelMedium>Starting equation:</LabelMedium>
<BodyMonospace className={styles.equationBody}>
{getTangentEquation(startCoords)}
</BodyMonospace>
</View>

{/* Points UI */}
<View className={styles.tile}>
<LabelLarge>Point 1:</LabelLarge>
<Strut size={spacing.small_12} />
<CoordinatePairInput
coord={startCoords[0]}
labels={["x", "y"]}
onChange={(value) => onChange([value, startCoords[1]])}
/>
</View>
<View className={styles.tile}>
<LabelLarge>Point 2:</LabelLarge>
<Strut size={spacing.small_12} />
<CoordinatePairInput
coord={startCoords[1]}
labels={["x", "y"]}
onChange={(value) => onChange([startCoords[0], value])}
/>
</View>
</>
);
};

export default StartCoordsTangent;
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ type GraphTypesThatHaveStartCoords =
| {type: "quadratic"}
| {type: "ray"}
| {type: "segment"}
| {type: "sinusoid"};
| {type: "sinusoid"}
| {type: "tangent"};

export type StartCoords = Extract<
PerseusGraphType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getQuadraticCoords,
getSegmentCoords,
getSinusoidCoords,
getTangentCoords,
} from "@khanacademy/perseus";
import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core";

Expand Down Expand Up @@ -64,6 +65,12 @@ export function getDefaultGraphStartCoords(
range,
step,
);
case "tangent":
return getTangentCoords(
{...graph, startCoords: undefined},
range,
step,
);
case "quadratic":
return getQuadraticCoords(
{...graph, startCoords: undefined},
Expand Down Expand Up @@ -93,6 +100,11 @@ export function getDefaultGraphStartCoords(
}
}

// TODO(LEMS-3956): Both getSinusoidEquation and getTangentEquation have two
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.

Should this TODO also be on getTangentEquation? Would it be a huge lift to do it as part of this PR?

// formatting quirks: (1) hardcoded "x - " and ") + " produce ugly strings
// like "x - -0.785" and "+ -1.000" when phase/offset are negative, and
// (2) no division-by-zero guard when both points share the same x-coordinate.
// These should be cleaned up together for consistency.
export const getSinusoidEquation = (startCoords: [Coord, Coord]) => {
// Get coefficients
// It's assumed that p1 is the root and p2 is the first peak
Expand All @@ -117,6 +129,30 @@ export const getSinusoidEquation = (startCoords: [Coord, Coord]) => {
);
};

export const getTangentEquation = (startCoords: [Coord, Coord]) => {
// Get coefficients
// It's assumed that p1 is the inflection point and p2 is a quarter-period away
const p1 = startCoords[0];
const p2 = startCoords[1];

// Resulting coefficients are canonical for this tangent curve
const amplitude = p2[1] - p1[1];
const angularFrequency = Math.PI / (4 * (p2[0] - p1[0]));
const phase = p1[0] * angularFrequency;
const verticalOffset = p1[1];

return (
"y = " +
amplitude.toFixed(3) +
"tan(" +
angularFrequency.toFixed(3) +
"x - " +
phase.toFixed(3) +
") + " +
verticalOffset.toFixed(3)
);
};

export const getQuadraticEquation = (startCoords: [Coord, Coord, Coord]) => {
const p1 = startCoords[0];
const p2 = startCoords[1];
Comment on lines 130 to 158
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟣 Pre-existing issue: getTangentEquation inherits two formatting quirks from getSinusoidEquation — (1) hardcoded "x - " and ") + " produce ugly strings like "x - -0.785" and "+ -1.000" when phase/offset are negative, and (2) no division-by-zero guard when both points share the same x-coordinate, producing "Infinity" in the equation. Both issues exist identically in getSinusoidEquation (lines 105-123) and are explicitly verified by existing sinusoid tests (line 269). Since this PR intentionally mirrors the sinusoid pattern, this is a pre-existing issue — but worth noting for a future cleanup across both functions.

Extended reasoning...

What the bug is

getTangentEquation (lines 125-153) builds an equation string using hardcoded operators "x - " and ") + ". When phase is negative, the output becomes "x - -0.785" instead of the mathematically cleaner "x + 0.785". Similarly, when verticalOffset is negative, it produces "+ -1.000" instead of "- 1.000". Additionally, if p2[0] === p1[0], the denominator in Math.PI / (4 * (p2[0] - p1[0])) is zero, producing Infinity for angularFrequency and phase, yielding an equation like "y = 0.000tan(Infinityx - Infinity) + 0.000".

Why this is pre-existing

Both issues are direct copies of the identical pattern in getSinusoidEquation (lines 105-123 of the same file). The sinusoid function uses Math.PI / (2 * (p2[0] - p1[0])) with the same division-by-zero vulnerability, and the same hardcoded "x - " and ") + " operators. The PR description explicitly states "StartCoordsTangent mirrors StartCoordsSinusoid exactly," confirming this was an intentional design choice to maintain consistency.

Proof via concrete example

For the formatting issue: given startCoords = [[1, -1], [3, 1]], we get amplitude = 2, angularFrequency = π/8 ≈ 0.393, phase = 0.393, verticalOffset = -1. The output is "y = 2.000tan(0.393x - 0.393) + -1.000" — note the "+ -1.000". For the division-by-zero: given startCoords = [[2, 0], [2, 3]], the denominator 4 * (2 - 2) = 0, so angularFrequency = Infinity, and the equation displays nonsensically.

Existing test evidence

The sinusoid tests in util.test.ts (around line 269) explicitly assert the ugly format: "y = 1.000sin(0.785x - -0.785) + 0.000" and "y = 2.000sin(1.571x - 0.000) + -1.000". This demonstrates the team has accepted this formatting as the current behavior. Changing only getTangentEquation would create an inconsistency between tangent and sinusoid equation displays.

Impact and recommended fix

The impact is purely cosmetic — these equations are only shown in the editor's start coordinates section, not to students. The division-by-zero case requires a content creator to manually type identical x-coordinates into the CoordinatePairInput fields, which is an unusual edge case (interactive dragging prevents it). For comparison, getQuadraticEquation (line 156-159) already has a denom === 0 guard that returns "Division by zero error". A future cleanup could add sign-aware formatting and division-by-zero guards to both getSinusoidEquation and getTangentEquation together for consistency.

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.

Yeah I think this is fine. We can chat with content creators to determine how important this is later.

Expand Down Expand Up @@ -183,15 +219,12 @@ export const shouldShowStartCoordsUI = (
graph.snapTo !== "sides"
);
case "none":
case "tangent":
// TODO(LEMS-3955): return true for tangent once
// StartCoordsSettingsInner and getDefaultGraphStartCoords
// handle the tangent type
return false;
case "angle":
case "circle":
case "linear":
case "linear-system":
case "tangent":
case "quadratic":
case "ray":
case "segment":
Expand Down
1 change: 1 addition & 0 deletions packages/perseus/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export {
getPolygonCoords,
getSegmentCoords,
getSinusoidCoords,
getTangentCoords,
getQuadraticCoords,
getAngleCoords,
} from "./widgets/interactive-graphs/reducer/initialize-graph-state";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ export function getSinusoidCoords(
return coords;
}

function getTangentCoords(
export function getTangentCoords(
graph: PerseusGraphTypeTangent,
range: [x: Interval, y: Interval],
step: [x: number, y: number],
Expand Down
Loading