Skip to content

[Interactive Graph] Add tangent graph option in the Interactive Graph Editor#3358

Merged
ivyolamit merged 5 commits intomainfrom
LEMS-3956/add-tangent-in-interactive-graph-editor
Mar 19, 2026
Merged

[Interactive Graph] Add tangent graph option in the Interactive Graph Editor#3358
ivyolamit merged 5 commits intomainfrom
LEMS-3956/add-tangent-in-interactive-graph-editor

Conversation

@ivyolamit
Copy link
Copy Markdown
Contributor

@ivyolamit ivyolamit commented Mar 14, 2026

Summary:

PR series to add tangent graph support to the Interactive Graph widget:

  1. Foundation — Add tangent graph type definitions and data schema
  2. Math layer — Add tangent math utilities to kmath
  3. State management — Reducer, actions, initialization, and test data
  4. Rendering — The tangent graph component, Storybook story, and AI utils
  5. Scoring — Add tangent scoring to the scoring package
  6. ▶️ Editor — Add tangent to answer type

This is the sixth and final PR in a series to add tangent graph support to the Interactive Graph widget (LEMS-3956). It adds editor support so content creators can create tangent exercises.


Add tangent graph option in the Interactive Graph Editor

  • Adds tangent as a selectable graph type in the editor, gated by the interactive-graph-tangent feature flag
  • Adds StartCoordsTangent component for configuring tangent start coordinates (inflection point + quarter-period point)
  • Adds tangent equation display in the start coordinates editor section
  • Exports getTangentCoords() from perseus for use by the editor's start-coords utilities
  • 0 new tests (existing parameterized tests cover the new code paths)
Implementation notes

Feature flag gating. The tangent OptionItem in GraphTypeSelector only renders when isFeatureOn("interactive-graph-tangent") is true. This is the only place the feature flag is checked — once a tangent graph type is persisted in content JSON, it renders and scores regardless of the flag. This follows the pattern used by other gated features (e.g., image-widget-upgrade-scale).

apiOptions prop threading. GraphTypeSelector needed access to apiOptions for isFeatureOn. Added apiOptions as an optional prop and threaded it from InteractiveGraphEditor.

getTangentCoords() export. This function was internal to initialize-graph-state.ts (as noted in PR 3's plan). Now exported and re-exported from perseus index, used by getDefaultGraphStartCoords and StartCoordsSettingsInner in the editor.

StartCoordsTangent mirrors StartCoordsSinusoid exactly. Two coordinate pair inputs (Point 1 / Point 2) and an equation display using getTangentEquation(). The equation helper follows the same pattern as getSinusoidEquation().

shouldShowStartCoordsUI flipped. Changed from false (set in PR 1 as a placeholder) to true for tangent, now that StartCoordsSettingsInner and getDefaultGraphStartCoords both handle the tangent type.

References

Co-Authored by Claude Code (Opus)

Issue: LEMS-3956

Test plan:

  • pnpm tsc — no type errors
  • pnpm lint — no lint errors
  • pnpm prettier . --check — formatting clean
  • pnpm knip — no unused exports
  • Tangent option appears in graph type dropdown when feature flag is on
  • Tangent option does NOT appear when feature flag is off
  • Start coordinates section appears for tangent graph type
  • Start coordinates reset button works for tangent
  • Tangent equation updates when start coordinates change
  • Existing editor tests pass (422 tests)

@ivyolamit ivyolamit self-assigned this Mar 14, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 14, 2026

🗄️ Schema Change: No Changes ✅

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 14, 2026

Size Change: +221 B (+0.05%)

Total Size: 488 kB

Filename Size Change
packages/perseus-editor/dist/es/index.js 100 kB +216 B (+0.22%)
packages/perseus/dist/es/index.js 189 kB +5 B (0%)
ℹ️ View Unchanged
Filename Size
packages/kas/dist/es/index.js 20.5 kB
packages/keypad-context/dist/es/index.js 1 kB
packages/kmath/dist/es/index.js 6.03 kB
packages/math-input/dist/es/index.js 98.5 kB
packages/math-input/dist/es/strings.js 1.61 kB
packages/perseus-core/dist/es/index.item-splitting.js 11.9 kB
packages/perseus-core/dist/es/index.js 25 kB
packages/perseus-linter/dist/es/index.js 8.82 kB
packages/perseus-score/dist/es/index.js 9.44 kB
packages/perseus-utils/dist/es/index.js 403 B
packages/perseus/dist/es/strings.js 7.66 kB
packages/pure-markdown/dist/es/index.js 1.39 kB
packages/simple-markdown/dist/es/index.js 6.71 kB

compressed-size-action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 14, 2026

🛠️ Item Splitting: No Changes ✅

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 14, 2026

npm Snapshot: Published

Good news!! We've packaged up the latest commit from this PR (2ae3707) and published it to npm. You
can install it using the tag PR3358.

Example:

pnpm add @khanacademy/perseus@PR3358

If you are working in Khan Academy's frontend, you can run the below command.

./dev/tools/bump_perseus_version.ts -t PR3358

If you are working in Khan Academy's webapp, you can run the below command.

./dev/tools/bump_perseus_version.js -t PR3358

@ivyolamit ivyolamit marked this pull request as ready for review March 14, 2026 00:20
@ivyolamit
Copy link
Copy Markdown
Contributor Author

Summary of the tangent implementation

PR Scope Note
1 Foundation — Add tangent graph type definitions and data schema #3345 for review
2 Math layer — Add tangent math utilities to kmath #3347 for review
3 State management — Reducer, actions, initialization, and test data #3353 for review
4 Rendering — The tangent graph component, Storybook story, and AI utils #3354 for review
5 Scoring — Add tangent scoring to the scoring package #3356 base PR (for review)
6 Editor — Add tangent to answer type #3358 this PR

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM — straightforward addition of tangent graph type to the editor, closely mirroring the existing sinusoid pattern and gated behind a feature flag.

Extended reasoning...

Overview

This PR adds tangent as a selectable graph type in the Interactive Graph Editor. It touches 8 files: a new StartCoordsTangent component, a getTangentEquation utility, feature-flag-gated dropdown option in GraphTypeSelector, apiOptions prop threading, export of getTangentCoords from perseus, and updates to shouldShowStartCoordsUI and getDefaultGraphStartCoords to handle the tangent type.

Security risks

None. All changes are editor-only UI code behind a feature flag (interactive-graph-tangent). No auth, crypto, permissions, or user-facing data handling is involved.

Level of scrutiny

Low scrutiny is appropriate. This is the sixth and final PR in a well-structured series. Every change follows the established sinusoid pattern exactly — the new component, equation function, and coord handling are mechanical mirrors of their sinusoid counterparts. The feature flag gating follows the same pattern as other gated features in the codebase.

Other factors

The two bugs found are both minor: (1) a nit about tangent missing from the GraphTypesThatHaveStartCoords type union, which has no runtime or compile-time impact since tangent's startCoords type (Coord[]) is structurally identical to sinusoid's already-included type; (2) a pre-existing formatting issue in equation string building that exists identically in getSinusoidEquation and is explicitly accepted by existing tests. Neither warrants blocking the PR. The CODEOWNERS file only has general team ownership for this path, with no specific individual owners for the interactive graph editor files.

Comment on lines 125 to 153
};

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];
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.

);
};

const styles = StyleSheet.create({
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.

Move this in packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/start-coords-tangent.css

ivyolamit added a commit that referenced this pull request Mar 17, 2026
#3345)

## Summary:

PR series to add tangent graph support to the Interactive Graph widget:

1. ▶️ [Foundation — Add tangent graph type definitions and data schema](#3345)
2. [Math layer —  Add tangent math utilities to kmath](#3347)
3. [State management — Reducer, actions, initialization, and test data](#3353)
4. [Rendering — The tangent graph component, Storybook story, and AI utils](#3354)
5. [Scoring — Add tangent scoring to the scoring package](#3356)
6. [Editor — Add tangent to answer type](#3358)

This is the first PR in a series to add tangent graph support to the Interactive Graph widget (LEMS-3955). It establishes the type foundation with zero runtime behavior change. The feature flag (`interactive-graph-tangent`) is added in #3344.

---

- Adds `PerseusGraphTypeTangent` and `TangentGraphCorrect` types to the data schema, following the sinusoid pattern
- Adds the JSON parser for the tangent graph type (`parsePerseusGraphTypeTangent`)
- Defines `TangentGraphState` interface (not yet exported or added to the `InteractiveGraphState` union — deferred to a later PR when reducer handlers are implemented)
- Adds `generateIGTangentGraph()` test data generator with unit tests
- Adds placeholder `case "tangent"` branches in all exhaustiveness switches affected by the new `PerseusGraphType` union member

<details>
<summary>Implementation notes</summary>

Adding `PerseusGraphTypeTangent` to the `PerseusGraphType` union triggers `UnreachableCaseError` in several switch statements. Placeholder cases were added to keep the build green:
- `interactive-graph-editor.tsx` — graph merging
- `start-coords/util.ts` — `shouldShowStartCoordsUI` returns `false` for tangent (downstream components `StartCoordsSettingsInner` and `getDefaultGraphStartCoords` don't handle tangent yet — returning `true` would show an empty section with a broken reset button)
- `interactive-graph-ai-utils.ts` — `getGraphOptionsForProps` + `getUserInput`
- `interactive-graph.tsx` — `getEquationString` (returns `""`)
- `initialize-graph-state.ts` — returns `type: "none"`

These placeholders will be replaced with real implementations in subsequent PRs.

`TangentGraphState` is intentionally **not exported** and **not added to the `InteractiveGraphState` union** in this PR. That union has its own set of exhaustiveness checks (`renderGraphElements`, `mafsStateToInteractiveGraph`, `getGradableGraph`), and adding to it requires reducer handlers to exist first. This happens in PR 3.

</details>

### References
- [Tangent Notes](https://github.com/Khan/perseus/blob/main/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/tangent.md)
- POC: #3311

Co-Authored by Claude Code (Opus)

Issue: LEMS-3955

## Test plan:
- [ ] `pnpm tsc` — no type errors
- [ ] `pnpm lint` — no lint errors
- [ ] `pnpm prettier . --check` — formatting clean
- [ ] `pnpm knip` — no unused exports
- [ ] Generator tests pass (`generateIGTangentGraph` default + all props)

Author: ivyolamit

Reviewers: ivyolamit, claude[bot], handeyeco, SonicScrewdriver

Required Reviewers:

Approved By: handeyeco

Checks: ✅ 10 checks were successful, ⏭️  1 check has been skipped

Pull Request URL: #3345
Copy link
Copy Markdown
Contributor

@handeyeco handeyeco left a comment

Choose a reason for hiding this comment

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

The CSS module is a red flag for me, I wonder if we need to double check Claude to make sure it's on the right track.

Comment thread .changeset/rich-toys-travel.md Outdated
Comment on lines +2 to +6
"@khanacademy/kmath": minor
"@khanacademy/perseus": minor
"@khanacademy/perseus-core": minor
"@khanacademy/perseus-editor": minor
"@khanacademy/perseus-score": minor
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.

Suggested change
"@khanacademy/kmath": minor
"@khanacademy/perseus": minor
"@khanacademy/perseus-core": minor
"@khanacademy/perseus-editor": minor
"@khanacademy/perseus-score": minor
"@khanacademy/perseus": minor
"@khanacademy/perseus-editor": minor

};

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

@@ -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.

}
}

// 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?

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...


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.

ivyolamit added a commit that referenced this pull request Mar 19, 2026
## Summary:

PR series to add tangent graph support to the Interactive Graph widget:

1. [Foundation — Add tangent graph type definitions and data schema](#3345)
2. ▶️ [Math layer —  Add tangent math utilities to kmath](#3347)
3. [State management — Reducer, actions, initialization, and test data](#3353)
4. [Rendering — The tangent graph component, Storybook story, and AI utils](#3354)
5. [Scoring — Add tangent scoring to the scoring package](#3356)
6. [Editor — Add tangent to answer type](#3358)

This is the second PR in a series to add tangent graph support to the Interactive Graph widget (LEMS-3955). It adds the pure math layer with no UI dependencies.

---

Added the tangent math utilities to kmath for supporting Tangent graph in Interactive Graph

- Adds `getTangentCoefficients()` to extract `[a, b, c, d]` from two control points for `f(x) = a * tan(b*x - c) + d`
- Adds `canonicalTangentCoefficients()` to normalize coefficients for scoring comparison (guarantees `b > 0`, phase in `[0, π)`)
- Adds `TangentCoefficient` and `NamedTangentCoefficient` types
- 13 new tests covering coefficient extraction and canonical normalization edge cases

<details>
<summary>Implementation notes</summary>

**Canonical normalization differs from the legacy Grapher widget version.** The legacy `canonicalTangentCoefficients` in `grapher-util.ts` guarantees both `a > 0` and `b > 0` using a `phase += π/2` step. However, this is mathematically incorrect for tangent — `tan(x + π/2) = -cot(x)`, not `-tan(x)`. The legacy version still works because its `areEqual` applies the same normalization to both sides, so the error cancels out.

Our version only guarantees `b > 0`, using the odd function identity `tan(-x) = -tan(x)`:
- If `b < 0`: flip signs of `a`, `b`, and `c`
- Normalize `c` to `[0, π)`

We intentionally do **not** replace the legacy version to avoid changing scoring behavior for existing Grapher tangent exercises.

</details>

### References
- [Tangent Notes](https://github.com/Khan/perseus/blob/main/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/tangent.md)
- POC: #3311

Co-Authored by Claude Code (Opus)

Issue: LEMS-3955

## Test plan:
- [ ] `pnpm tsc` — no type errors
- [ ] `pnpm lint` — no lint errors
- [ ] `pnpm prettier . --check` — formatting clean
- [ ] `pnpm knip` — no unused exports
- [ ] `canonicalTangentCoefficients` tests pass (negative b, phase normalization, equivalent curves, preserved negative amplitude)
- [ ] `getTangentCoefficients` tests pass (basic, vertical offset, phase shift, negative amplitude, negative angular frequency)


## Test plan:

Author: ivyolamit

Reviewers: claude[bot], ivyolamit, handeyeco, SonicScrewdriver

Required Reviewers:

Approved By: SonicScrewdriver

Checks: ⏭️  1 check has been skipped, ✅ 10 checks were successful

Pull Request URL: #3347
ivyolamit added a commit that referenced this pull request Mar 19, 2026
…3353)

## Summary:

PR series to add tangent graph support to the Interactive Graph widget:

1. [Foundation — Add tangent graph type definitions and data schema](#3345)
2. [Math layer —  Add tangent math utilities to kmath](#3347)
3. ▶️ [State management — Reducer, actions, initialization, and test data](#3353)
4. [Rendering — The tangent graph component, Storybook story, and AI utils](#3354)
5. [Scoring — Add tangent scoring to the scoring package](#3356)
6. [Editor — Add tangent to answer type](#3358)

This is the third PR in a series to add tangent graph support to the Interactive Graph widget (LEMS-3955). It wires up the state management layer — the reducer, initialization, actions, and gradable graph serialization.

---

Add tangent graph state management and reducer for supporting Tangent graph in Interactive Graph

- Exports `TangentGraphState` and adds it to the `InteractiveGraphState` union
- Adds `tangent.movePoint` action and reducer case with same-x constraint (prevents division by zero in `getTangentCoefficients`)
- Adds real tangent initialization in `initializeGraphState` via `getTangentCoords()` (default coords `[[0.5, 0.5], [0.75, 0.75]]`)
- Adds tangent case to `getGradableGraph` for scoring serialization
- Adds `withTangent()` builder method and `TangentGraphConfig` class for test data construction
- Adds `tangentQuestion` and `tangentQuestionWithDefaultCorrect` test data
- Adds tangent serialization in `mafsStateToInteractiveGraph`
- 13 new tests across 5 test files

<details>
<summary>Implementation notes</summary>

**InteractiveGraphState union update.** Adding `TangentGraphState` to the union triggers `UnreachableCaseError` in `renderGraphElements` (mafs-graph.tsx). A placeholder case returns `null` (no rendering) — replaced with real rendering in PR 4.

**Same-x constraint.** The `doMovePoint` tangent case rejects moves that would place both control points on the same vertical line. This mirrors the sinusoid constraint and prevents `getTangentCoefficients` from producing `Infinity` for `angularFrequency` (since it divides by `p2[0] - p1[0]`).

**`getTangentCoords()` is not exported.** It's only called within `initialize-graph-state.ts`. It will be exported in PR 6 (editor) when `start-coords/util.ts` needs it.

**`mafsStateToInteractiveGraph` tangent case is the real implementation** (not a placeholder) — it simply returns `{ ...originalGraph, coords: state.coords }`, same as sinusoid.

</details>

### References
- [Tangent Notes](https://github.com/Khan/perseus/blob/main/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/tangent.md)
- POC: #3311

Co-Authored by Claude Code (Opus)

Issue: LEMS-3955

## Test plan:
- [ ] `pnpm tsc` — no type errors
- [ ] `pnpm lint` — no lint errors
- [ ] `pnpm prettier . --check` — formatting clean
- [ ] `pnpm knip` — no unused exports
- [ ] Initialization tests pass (given coords, startCoords, defaults)
- [ ] Reducer tests pass (same-x rejection, out-of-bounds rejection, valid move)
- [ ] `getGradableGraph` tangent test passes
- [ ] `mafsStateToInteractiveGraph` tangent serialization test passes
- [ ] Tangent renders in parameterized "should render" tests

Author: ivyolamit

Reviewers: claude[bot], handeyeco, ivyolamit, SonicScrewdriver

Required Reviewers:

Approved By: handeyeco

Checks: ✅ 10 checks were successful, ⏭️  1 check has been skipped

Pull Request URL: #3353
ivyolamit added a commit that referenced this pull request Mar 19, 2026
…tion string (#3354)

## Summary:

PR series to add tangent graph support to the Interactive Graph widget:

1. [Foundation — Add tangent graph type definitions and data schema](#3345)
2. [Math layer —  Add tangent math utilities to kmath](#3347)
3. [State management — Reducer, actions, initialization, and test data](#3353)
4. ▶️ [Rendering — The tangent graph component, Storybook story, and AI utils](#3354)
5. [Scoring — Add tangent scoring to the scoring package](#3356)
6. [Editor — Add tangent to answer type](#3358)

This is the fourth PR in a series to add tangent graph support to the Interactive Graph widget (LEMS-3955). It adds the rendering layer — the Mafs component, accessibility strings, equation string generation, and Storybook coverage.

---

Created the tangent graph visual component, add Storybook coverage, SR strings, and equation string for supporting Tangent graph in Interactive Graph.

- Adds the tangent graph visual component (`tangent.tsx`) with `renderTangentGraph()`, `computeTangent()`, keyboard constraints, and screen reader descriptions
- Adds 5 screen reader strings for tangent graph accessibility (`srTangentGraph`, `srTangentInflectionPoint`, `srTangentSecondPoint`, `srTangentDescription`, `srTangentInteractiveElements`)
- Replaces the `mafs-graph.tsx` placeholder with real `renderTangentGraph()` call
- Replaces the `interactive-graph.tsx` equation string placeholder with `getTangentEquationString()`
- Adds Tangent Storybook story
- 7 new tests for the tangent graph component

<details>
<summary>Implementation notes</summary>

**Tangent component follows the sinusoid pattern.** `tangent.tsx` mirrors `sinusoid.tsx` structurally: two movable control points, coefficient calculation with a ref-based fallback for invalid states, and the same keyboard constraint logic that prevents same-x points.

**Asymptote handling (vertical line bug fix).** Mafs `Plot.OfX` renders a single SVG `<path>` that draws vertical lines across discontinuities at asymptotes. To fix this, the tangent curve is split into segments between asymptotes:
- `getAsymptotePositions()` computes asymptote x-positions within the visible range: `x = (c + π/2 + nπ) / b`
- `getPlotSegments()` splits the x-range into segments between asymptotes with a small epsilon margin (0.01)
- Each segment is rendered as a separate `Plot.OfX` with a `domain` prop, so Mafs never draws across a discontinuity
- `computeTangent()` also returns NaN near asymptotes as a defensive backup. The proximity formula was corrected from the POC — the POC's `((arg / Math.PI + 0.5) % 1) - 0.5` measures distance from zero crossings (inflection points), not asymptotes. The corrected formula `((arg - Math.PI/2) / Math.PI) % 1` correctly targets asymptotes at `arg = π/2 + nπ`.
- This approach was validated in the POC (commit 204f3f2)

**Two `getTangentCoefficients` functions exist.** The one in `tangent.tsx` returns `NamedTangentCoefficient | undefined` (named object with `undefined` fallback for same-x points) for rendering use. The one in `kmath/coefficients.ts` returns `TangentCoefficient` (numeric tuple, returns `Infinity` for same-x) for scoring use. The UI prevents the same-x case via the reducer's same-x guard, so the difference only matters as a defensive measure.

**Screen reader descriptions.** The tangent graph uses "inflection point" for the first control point (where the curve crosses the midline) and "control point" for the second point (a quarter-period away). This differs from sinusoid which uses "midline intersection" and "maximum/minimum point" — tangent doesn't have a meaningful max/min since it approaches ±∞.

**Equation string.** `getTangentEquationString()` formats `y = a*tan(b*x - c) + d` using the same pattern as `getSinusoidEquationString()` but using `getTangentCoefficients` from kmath.

**No feature flag gate in rendering.** The tangent graph renders unconditionally once the graph type is set to "tangent". The feature flag gate is in the editor (PR 6), which controls whether content creators can select "tangent" as a graph type. This follows the existing pattern — no other graph types check feature flags at render time.

</details>

### References
- [Tangent Notes](https://github.com/Khan/perseus/blob/main/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/tangent.md)
- POC: #3311

Co-Authored by Claude Code (Opus)

Issue: LEMS-3955

## Test plan:
- [ ] `pnpm tsc` — no type errors
- [ ] `pnpm lint` — no lint errors
- [ ] `pnpm prettier . --check` — formatting clean
- [ ] `pnpm knip` — no unused exports
- [ ] SR tests pass (aria labels for graph, inflection point, control point, description, interactive elements)
- [ ] Coefficient calculation test passes
- [ ] Tangent computation test passes
- [ ] Invalid coefficient test passes (same-x returns undefined)
- [ ] Keyboard constraint test passes (avoids same-x)
- [ ] Tangent story renders in Storybook (`pnpm storybook`)

Author: ivyolamit

Reviewers: claude[bot], ivyolamit, handeyeco, SonicScrewdriver

Required Reviewers:

Approved By: handeyeco

Checks: ⏭️  1 check has been skipped, ✅ 10 checks were successful

Pull Request URL: #3354
@ivyolamit ivyolamit force-pushed the LEMS-3955/tangent-scoring branch from 355ee58 to d96aca9 Compare March 19, 2026 20:32
ivyolamit added a commit that referenced this pull request Mar 19, 2026
## Summary:

PR series to add tangent graph support to the Interactive Graph widget:

1. [Foundation — Add tangent graph type definitions and data schema](#3345)
2. [Math layer —  Add tangent math utilities to kmath](#3347)
3. [State management — Reducer, actions, initialization, and test data](#3353)
4. [Rendering — The tangent graph component, Storybook story, and AI utils](#3354)
5. ▶️ [Scoring — Add tangent scoring to the scoring package](#3356)
6. [Editor — Add tangent to answer type](#3358)

This is the fifth PR in a series to add tangent graph support to the Interactive Graph widget (LEMS-3955). It adds the scoring layer — the final piece needed for tangent exercises to be fully functional (behind the feature flag).

---

Add tangent graph scoring to support the Tangent graph in Interactive Graph

- Adds tangent scoring to `scoreInteractiveGraph()` using `getTangentCoefficients` and `canonicalTangentCoefficients` from kmath
- Follows the sinusoid scoring pattern: extract coefficients from both guess and rubric, canonicalize, then compare
- 6 new tests covering invalid input, correct/incorrect answers, equivalent curves, and negative amplitude

<details>
<summary>Implementation notes</summary>

**Scoring follows the sinusoid pattern exactly.** The tangent scoring block extracts coefficients from both the user's guess and the rubric's correct answer using `getTangentCoefficients()` (from kmath), canonicalizes both with `canonicalTangentCoefficients()`, and compares with `approximateDeepEqual()`. This handles equivalent curves that use different control points (e.g., shifted by a full period).

**Uses kmath's `canonicalTangentCoefficients`, NOT the legacy grapher-util version.** The kmath version (PR 2) only guarantees `b > 0`, which is mathematically correct for tangent. The legacy version guarantees both `a > 0` and `b > 0` using a mathematically incorrect phase shift. See PR 2 implementation notes for details.

</details>

### References
- [Tangent Notes](https://github.com/Khan/perseus/blob/main/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/tangent.md)
- POC: #3311

Co-Authored by Claude Code (Opus)

Issue: LEMS-3955

## Test plan:
- [ ] `pnpm tsc` — no type errors
- [ ] `pnpm lint` — no lint errors
- [ ] `pnpm prettier . --check` — formatting clean
- [ ] `pnpm knip` — no unused exports
- [ ] Invalid input tests pass (undefined guess, missing coords)
- [ ] Correct answer test passes
- [ ] Incorrect answer test passes
- [ ] Equivalent curves test passes (period-shifted control points)
- [ ] Negative amplitude test passes

Author: ivyolamit

Reviewers: claude[bot], handeyeco, SonicScrewdriver

Required Reviewers:

Approved By: handeyeco

Checks: ⏭️  1 check has been skipped, ✅ 10 checks were successful

Pull Request URL: #3356
Base automatically changed from LEMS-3955/tangent-scoring to main March 19, 2026 20:39
@ivyolamit ivyolamit force-pushed the LEMS-3956/add-tangent-in-interactive-graph-editor branch from b4fb7eb to 1555df0 Compare March 19, 2026 20:45
Copy link
Copy Markdown
Contributor

@SonicScrewdriver SonicScrewdriver left a comment

Choose a reason for hiding this comment

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

I think this is good to go!

@ivyolamit ivyolamit merged commit 8c50317 into main Mar 19, 2026
11 checks passed
@ivyolamit ivyolamit deleted the LEMS-3956/add-tangent-in-interactive-graph-editor branch March 19, 2026 21:51
ivyolamit added a commit that referenced this pull request Mar 24, 2026
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.


# Releases
## @khanacademy/perseus-editor@30.0.0

### Major Changes

- [#3332](#3332)
[`604b3a6c25`](604b3a6)
Thanks [@benchristel](https://github.com/benchristel)! - The `options`
parameter of the `serialize` method of `EditorPage` and `Editor` has
been removed.


- [#3386](#3386)
[`7e76fbbc2f`](7e76fbb)
Thanks [@benchristel](https://github.com/benchristel)! - The `serialize`
methods of classes in `@khanacademy/perseus-editor` no longer use arrow
function syntax. Callers should not unbind them from the class instance.

Additionally, the `Editor` component no longer accepts a `replace` prop
(used for hints), and its serialize method no longer returns `replace`.
The `replace` prop was only used in `serialize`. Users of the `Editor`
component should manage hints' `replace` setting themselves.

### Minor Changes

- [#3395](#3395)
[`97223334ea`](9722333)
Thanks [@SonicScrewdriver](https://github.com/SonicScrewdriver)! -
Implementation of Editor support for Exponential Graph


- [#3352](#3352)
[`b681e00a4f`](b681e00)
Thanks [@handeyeco](https://github.com/handeyeco)! - Add editor support
for AbsoluteValue


- [#3348](#3348)
[`b1557c2a73`](b1557c2)
Thanks [@handeyeco](https://github.com/handeyeco)! - Add schema for
AbsoluteValue graph


- [#3345](#3345)
[`dde985f3b5`](dde985f)
Thanks [@ivyolamit](https://github.com/ivyolamit)! - Add tangent type
definitions, this is the initial implementation for supporting Tangent
graph in Interactive Graph


- [#3358](#3358)
[`8c503171b1`](8c50317)
Thanks [@ivyolamit](https://github.com/ivyolamit)! - Add tangent graph
option in the Interactive Graph Editor


- [#3376](#3376)
[`8aa0a77886`](8aa0a77)
Thanks [@SonicScrewdriver](https://github.com/SonicScrewdriver)! -
Creation of new Types, Schema, and Kmath utilities for Exponential Graph

### Patch Changes

- [#3396](#3396)
[`35fa9133db`](35fa913)
Thanks [@nishasy](https://github.com/nishasy)! - [Image] | (CX) | Add a
linter warning for images with no size


- [#3390](#3390)
[`d22c50dc2a`](d22c50d)
Thanks [@nishasy](https://github.com/nishasy)! - [Image] | (CX) | Make
the 125 character alt text warning less aggressive


- [#3372](#3372)
[`3cdb09813d`](3cdb098)
Thanks [@nishasy](https://github.com/nishasy)! - [Image] | (UX) |
Upscale Graphies within Explore Image Modal


- [#3391](#3391)
[`2f285ee161`](2f285ee)
Thanks [@nishasy](https://github.com/nishasy)! - [Image] | (CX) | Add
character counter to alt text field


- [#3374](#3374)
[`cd73c99ba3`](cd73c99)
Thanks [@ivyolamit](https://github.com/ivyolamit)! - Remove incorrect
usage of the feature flag setting in one of the test

- Updated dependencies
\[[`f18c0d9b6f`](f18c0d9),
[`a022e751d6`](a022e75),
[`35fa9133db`](35fa913),
[`54db3fd4bd`](54db3fd),
[`97223334ea`](9722333),
[`027a5edbda`](027a5ed),
[`ae0538d0a7`](ae0538d),
[`005e13d784`](005e13d),
[`3cdb09813d`](3cdb098),
[`afcff9f96f`](afcff9f),
[`75f184e5a7`](75f184e),
[`4b2a7c85db`](4b2a7c8),
[`5e1acd01f8`](5e1acd0),
[`b681e00a4f`](b681e00),
[`d99f1c0259`](d99f1c0),
[`54eee35d65`](54eee35),
[`b1557c2a73`](b1557c2),
[`dde985f3b5`](dde985f),
[`56e7dbe9a2`](56e7dbe),
[`85f9cd46fc`](85f9cd4),
[`8c503171b1`](8c50317),
[`3aca3dcdf4`](3aca3dc),
[`9f29bc7161`](9f29bc7),
[`7034844845`](7034844),
[`8aa0a77886`](8aa0a77),
[`003aca7612`](003aca7)]:
    -   @khanacademy/perseus-linter@4.9.0
    -   @khanacademy/perseus-score@8.4.0
    -   @khanacademy/perseus-core@23.7.0
    -   @khanacademy/perseus@76.1.0
    -   @khanacademy/kmath@2.3.0
    -   @khanacademy/keypad-context@3.2.40
    -   @khanacademy/math-input@26.4.10

## @khanacademy/kmath@2.3.0

### Minor Changes

- [#3351](#3351)
[`005e13d784`](005e13d)
Thanks [@handeyeco](https://github.com/handeyeco)! - Add scoring for
AbsoluteValue


- [#3347](#3347)
[`d99f1c0259`](d99f1c0)
Thanks [@ivyolamit](https://github.com/ivyolamit)! - Add the tangent
math utilities to kmath for supporting Tangent graph in Interactive
Graph


- [#3376](#3376)
[`8aa0a77886`](8aa0a77)
Thanks [@SonicScrewdriver](https://github.com/SonicScrewdriver)! -
Creation of new Types, Schema, and Kmath utilities for Exponential Graph

### Patch Changes

- Updated dependencies
\[[`54db3fd4bd`](54db3fd),
[`ae0538d0a7`](ae0538d),
[`005e13d784`](005e13d),
[`b1557c2a73`](b1557c2),
[`dde985f3b5`](dde985f),
[`8aa0a77886`](8aa0a77)]:
    -   @khanacademy/perseus-core@23.7.0

## @khanacademy/perseus@76.1.0

### Minor Changes

- [#3350](#3350)
[`75f184e5a7`](75f184e)
Thanks [@handeyeco](https://github.com/handeyeco)! - Implement
AbsoluteValue rendering


- [#3354](#3354)
[`4b2a7c85db`](4b2a7c8)
Thanks [@ivyolamit](https://github.com/ivyolamit)! - Created the tangent
graph visual component, add Storybook coverage, SR strings, and equation
string for supporting Tangent graph in Interactive Graph


- [#3353](#3353)
[`5e1acd01f8`](5e1acd0)
Thanks [@ivyolamit](https://github.com/ivyolamit)! - Add tangent graph
state management and reducer for supporting Tangent graph in Interactive
Graph


- [#3352](#3352)
[`b681e00a4f`](b681e00)
Thanks [@handeyeco](https://github.com/handeyeco)! - Add editor support
for AbsoluteValue


- [#3348](#3348)
[`b1557c2a73`](b1557c2)
Thanks [@handeyeco](https://github.com/handeyeco)! - Add schema for
AbsoluteValue graph


- [#3345](#3345)
[`dde985f3b5`](dde985f)
Thanks [@ivyolamit](https://github.com/ivyolamit)! - Add tangent type
definitions, this is the initial implementation for supporting Tangent
graph in Interactive Graph


- [#3349](#3349)
[`56e7dbe9a2`](56e7dbe)
Thanks [@handeyeco](https://github.com/handeyeco)! - Add state
management for AbsoluteValue


- [#3377](#3377)
[`85f9cd46fc`](85f9cd4)
Thanks [@SonicScrewdriver](https://github.com/SonicScrewdriver)! -
Implementation of state management logic for new Exponential graph


- [#3358](#3358)
[`8c503171b1`](8c50317)
Thanks [@ivyolamit](https://github.com/ivyolamit)! - Add tangent graph
option in the Interactive Graph Editor


- [#3393](#3393)
[`9f29bc7161`](9f29bc7)
Thanks [@SonicScrewdriver](https://github.com/SonicScrewdriver)! -
Rendering logic for new Exponential Graph


- [#3376](#3376)
[`8aa0a77886`](8aa0a77)
Thanks [@SonicScrewdriver](https://github.com/SonicScrewdriver)! -
Creation of new Types, Schema, and Kmath utilities for Exponential Graph

### Patch Changes

- [#3329](#3329)
[`027a5edbda`](027a5ed)
Thanks [@Myranae](https://github.com/Myranae)! - Fix image bug by
batching setState calls in setupGraphie


- [#3372](#3372)
[`3cdb09813d`](3cdb098)
Thanks [@nishasy](https://github.com/nishasy)! - [Image] | (UX) |
Upscale Graphies within Explore Image Modal


- [#3365](#3365)
[`afcff9f96f`](afcff9f)
Thanks [@jeremywiebe](https://github.com/jeremywiebe)! - Improve
ordering of Props type for `Renderer` component


- [#3367](#3367)
[`54eee35d65`](54eee35)
Thanks [@nishasy](https://github.com/nishasy)! - [Image] | (UX) | Show
image in explore modal even when size is undefined


- [#3407](#3407)
[`3aca3dcdf4`](3aca3dc)
Thanks [@Myranae](https://github.com/Myranae)! - Improve a11y with
graded group set


- [#3385](#3385)
[`003aca7612`](003aca7)
Thanks [@Myranae](https://github.com/Myranae)! - Small fix to prevent
pip duplication in Graded Group Sets

- Updated dependencies
\[[`f18c0d9b6f`](f18c0d9),
[`a022e751d6`](a022e75),
[`35fa9133db`](35fa913),
[`54db3fd4bd`](54db3fd),
[`97223334ea`](9722333),
[`ae0538d0a7`](ae0538d),
[`005e13d784`](005e13d),
[`d99f1c0259`](d99f1c0),
[`b1557c2a73`](b1557c2),
[`dde985f3b5`](dde985f),
[`7034844845`](7034844),
[`8aa0a77886`](8aa0a77)]:
    -   @khanacademy/perseus-linter@4.9.0
    -   @khanacademy/perseus-score@8.4.0
    -   @khanacademy/perseus-core@23.7.0
    -   @khanacademy/kmath@2.3.0
    -   @khanacademy/keypad-context@3.2.40
    -   @khanacademy/math-input@26.4.10

## @khanacademy/perseus-core@23.7.0

### Minor Changes

- [#3405](#3405)
[`54db3fd4bd`](54db3fd)
Thanks [@benchristel](https://github.com/benchristel)! -
`@khanacademy/perseus-core` now exports a
`removeOrphanedWidgetsFromPerseusItem` function, which removes
unreferenced widgets from a `PerseusItem`'s question and hints.


- [#3351](#3351)
[`005e13d784`](005e13d)
Thanks [@handeyeco](https://github.com/handeyeco)! - Add scoring for
AbsoluteValue


- [#3348](#3348)
[`b1557c2a73`](b1557c2)
Thanks [@handeyeco](https://github.com/handeyeco)! - Add schema for
AbsoluteValue graph


- [#3345](#3345)
[`dde985f3b5`](dde985f)
Thanks [@ivyolamit](https://github.com/ivyolamit)! - Add tangent type
definitions, this is the initial implementation for supporting Tangent
graph in Interactive Graph


- [#3376](#3376)
[`8aa0a77886`](8aa0a77)
Thanks [@SonicScrewdriver](https://github.com/SonicScrewdriver)! -
Creation of new Types, Schema, and Kmath utilities for Exponential Graph

### Patch Changes

- [#3357](#3357)
[`ae0538d0a7`](ae0538d)
Thanks [@jeremywiebe](https://github.com/jeremywiebe)! - Improve code
documentation for all data-schema and user-input types

## @khanacademy/perseus-linter@4.9.0

### Minor Changes

- [#3381](#3381)
[`f18c0d9b6f`](f18c0d9)
Thanks [@anakaren-rojas](https://github.com/anakaren-rojas)! - Adds new
linters for parsed objects


- [#3395](#3395)
[`97223334ea`](9722333)
Thanks [@SonicScrewdriver](https://github.com/SonicScrewdriver)! -
Implementation of Editor support for Exponential Graph

### Patch Changes

- [#3396](#3396)
[`35fa9133db`](35fa913)
Thanks [@nishasy](https://github.com/nishasy)! - [Image] | (CX) | Add a
linter warning for images with no size

- Updated dependencies
\[[`54db3fd4bd`](54db3fd),
[`ae0538d0a7`](ae0538d),
[`005e13d784`](005e13d),
[`d99f1c0259`](d99f1c0),
[`b1557c2a73`](b1557c2),
[`dde985f3b5`](dde985f),
[`8aa0a77886`](8aa0a77)]:
    -   @khanacademy/perseus-core@23.7.0
    -   @khanacademy/kmath@2.3.0

## @khanacademy/perseus-score@8.4.0

### Minor Changes

- [#3356](#3356)
[`a022e751d6`](a022e75)
Thanks [@ivyolamit](https://github.com/ivyolamit)! - Add tangent graph
scoring to support the Tangent graph in Interactive Graph


- [#3351](#3351)
[`005e13d784`](005e13d)
Thanks [@handeyeco](https://github.com/handeyeco)! - Add scoring for
AbsoluteValue


- [#3394](#3394)
[`7034844845`](7034844)
Thanks [@SonicScrewdriver](https://github.com/SonicScrewdriver)! -
Implementation of new scoring logic for Exponential Graph

### Patch Changes

- Updated dependencies
\[[`54db3fd4bd`](54db3fd),
[`ae0538d0a7`](ae0538d),
[`005e13d784`](005e13d),
[`d99f1c0259`](d99f1c0),
[`b1557c2a73`](b1557c2),
[`dde985f3b5`](dde985f),
[`8aa0a77886`](8aa0a77)]:
    -   @khanacademy/perseus-core@23.7.0
    -   @khanacademy/kmath@2.3.0

## @khanacademy/keypad-context@3.2.40

### Patch Changes

- Updated dependencies
\[[`54db3fd4bd`](54db3fd),
[`ae0538d0a7`](ae0538d),
[`005e13d784`](005e13d),
[`b1557c2a73`](b1557c2),
[`dde985f3b5`](dde985f),
[`8aa0a77886`](8aa0a77)]:
    -   @khanacademy/perseus-core@23.7.0

## @khanacademy/math-input@26.4.10

### Patch Changes

- Updated dependencies
\[[`54db3fd4bd`](54db3fd),
[`ae0538d0a7`](ae0538d),
[`005e13d784`](005e13d),
[`b1557c2a73`](b1557c2),
[`dde985f3b5`](dde985f),
[`8aa0a77886`](8aa0a77)]:
    -   @khanacademy/perseus-core@23.7.0
    -   @khanacademy/keypad-context@3.2.40
catandthemachines pushed a commit that referenced this pull request Apr 1, 2026
#3345)

## Summary:

PR series to add tangent graph support to the Interactive Graph widget:

1. ▶️ [Foundation — Add tangent graph type definitions and data schema](#3345)
2. [Math layer —  Add tangent math utilities to kmath](#3347)
3. [State management — Reducer, actions, initialization, and test data](#3353)
4. [Rendering — The tangent graph component, Storybook story, and AI utils](#3354)
5. [Scoring — Add tangent scoring to the scoring package](#3356)
6. [Editor — Add tangent to answer type](#3358)

This is the first PR in a series to add tangent graph support to the Interactive Graph widget (LEMS-3955). It establishes the type foundation with zero runtime behavior change. The feature flag (`interactive-graph-tangent`) is added in #3344.

---

- Adds `PerseusGraphTypeTangent` and `TangentGraphCorrect` types to the data schema, following the sinusoid pattern
- Adds the JSON parser for the tangent graph type (`parsePerseusGraphTypeTangent`)
- Defines `TangentGraphState` interface (not yet exported or added to the `InteractiveGraphState` union — deferred to a later PR when reducer handlers are implemented)
- Adds `generateIGTangentGraph()` test data generator with unit tests
- Adds placeholder `case "tangent"` branches in all exhaustiveness switches affected by the new `PerseusGraphType` union member

<details>
<summary>Implementation notes</summary>

Adding `PerseusGraphTypeTangent` to the `PerseusGraphType` union triggers `UnreachableCaseError` in several switch statements. Placeholder cases were added to keep the build green:
- `interactive-graph-editor.tsx` — graph merging
- `start-coords/util.ts` — `shouldShowStartCoordsUI` returns `false` for tangent (downstream components `StartCoordsSettingsInner` and `getDefaultGraphStartCoords` don't handle tangent yet — returning `true` would show an empty section with a broken reset button)
- `interactive-graph-ai-utils.ts` — `getGraphOptionsForProps` + `getUserInput`
- `interactive-graph.tsx` — `getEquationString` (returns `""`)
- `initialize-graph-state.ts` — returns `type: "none"`

These placeholders will be replaced with real implementations in subsequent PRs.

`TangentGraphState` is intentionally **not exported** and **not added to the `InteractiveGraphState` union** in this PR. That union has its own set of exhaustiveness checks (`renderGraphElements`, `mafsStateToInteractiveGraph`, `getGradableGraph`), and adding to it requires reducer handlers to exist first. This happens in PR 3.

</details>

### References
- [Tangent Notes](https://github.com/Khan/perseus/blob/main/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/tangent.md)
- POC: #3311

Co-Authored by Claude Code (Opus)

Issue: LEMS-3955

## Test plan:
- [ ] `pnpm tsc` — no type errors
- [ ] `pnpm lint` — no lint errors
- [ ] `pnpm prettier . --check` — formatting clean
- [ ] `pnpm knip` — no unused exports
- [ ] Generator tests pass (`generateIGTangentGraph` default + all props)

Author: ivyolamit

Reviewers: ivyolamit, claude[bot], handeyeco, SonicScrewdriver

Required Reviewers:

Approved By: handeyeco

Checks: ✅ 10 checks were successful, ⏭️  1 check has been skipped

Pull Request URL: #3345
catandthemachines pushed a commit that referenced this pull request Apr 1, 2026
## Summary:

PR series to add tangent graph support to the Interactive Graph widget:

1. [Foundation — Add tangent graph type definitions and data schema](#3345)
2. ▶️ [Math layer —  Add tangent math utilities to kmath](#3347)
3. [State management — Reducer, actions, initialization, and test data](#3353)
4. [Rendering — The tangent graph component, Storybook story, and AI utils](#3354)
5. [Scoring — Add tangent scoring to the scoring package](#3356)
6. [Editor — Add tangent to answer type](#3358)

This is the second PR in a series to add tangent graph support to the Interactive Graph widget (LEMS-3955). It adds the pure math layer with no UI dependencies.

---

Added the tangent math utilities to kmath for supporting Tangent graph in Interactive Graph

- Adds `getTangentCoefficients()` to extract `[a, b, c, d]` from two control points for `f(x) = a * tan(b*x - c) + d`
- Adds `canonicalTangentCoefficients()` to normalize coefficients for scoring comparison (guarantees `b > 0`, phase in `[0, π)`)
- Adds `TangentCoefficient` and `NamedTangentCoefficient` types
- 13 new tests covering coefficient extraction and canonical normalization edge cases

<details>
<summary>Implementation notes</summary>

**Canonical normalization differs from the legacy Grapher widget version.** The legacy `canonicalTangentCoefficients` in `grapher-util.ts` guarantees both `a > 0` and `b > 0` using a `phase += π/2` step. However, this is mathematically incorrect for tangent — `tan(x + π/2) = -cot(x)`, not `-tan(x)`. The legacy version still works because its `areEqual` applies the same normalization to both sides, so the error cancels out.

Our version only guarantees `b > 0`, using the odd function identity `tan(-x) = -tan(x)`:
- If `b < 0`: flip signs of `a`, `b`, and `c`
- Normalize `c` to `[0, π)`

We intentionally do **not** replace the legacy version to avoid changing scoring behavior for existing Grapher tangent exercises.

</details>

### References
- [Tangent Notes](https://github.com/Khan/perseus/blob/main/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/tangent.md)
- POC: #3311

Co-Authored by Claude Code (Opus)

Issue: LEMS-3955

## Test plan:
- [ ] `pnpm tsc` — no type errors
- [ ] `pnpm lint` — no lint errors
- [ ] `pnpm prettier . --check` — formatting clean
- [ ] `pnpm knip` — no unused exports
- [ ] `canonicalTangentCoefficients` tests pass (negative b, phase normalization, equivalent curves, preserved negative amplitude)
- [ ] `getTangentCoefficients` tests pass (basic, vertical offset, phase shift, negative amplitude, negative angular frequency)


## Test plan:

Author: ivyolamit

Reviewers: claude[bot], ivyolamit, handeyeco, SonicScrewdriver

Required Reviewers:

Approved By: SonicScrewdriver

Checks: ⏭️  1 check has been skipped, ✅ 10 checks were successful

Pull Request URL: #3347
catandthemachines pushed a commit that referenced this pull request Apr 1, 2026
…3353)

## Summary:

PR series to add tangent graph support to the Interactive Graph widget:

1. [Foundation — Add tangent graph type definitions and data schema](#3345)
2. [Math layer —  Add tangent math utilities to kmath](#3347)
3. ▶️ [State management — Reducer, actions, initialization, and test data](#3353)
4. [Rendering — The tangent graph component, Storybook story, and AI utils](#3354)
5. [Scoring — Add tangent scoring to the scoring package](#3356)
6. [Editor — Add tangent to answer type](#3358)

This is the third PR in a series to add tangent graph support to the Interactive Graph widget (LEMS-3955). It wires up the state management layer — the reducer, initialization, actions, and gradable graph serialization.

---

Add tangent graph state management and reducer for supporting Tangent graph in Interactive Graph

- Exports `TangentGraphState` and adds it to the `InteractiveGraphState` union
- Adds `tangent.movePoint` action and reducer case with same-x constraint (prevents division by zero in `getTangentCoefficients`)
- Adds real tangent initialization in `initializeGraphState` via `getTangentCoords()` (default coords `[[0.5, 0.5], [0.75, 0.75]]`)
- Adds tangent case to `getGradableGraph` for scoring serialization
- Adds `withTangent()` builder method and `TangentGraphConfig` class for test data construction
- Adds `tangentQuestion` and `tangentQuestionWithDefaultCorrect` test data
- Adds tangent serialization in `mafsStateToInteractiveGraph`
- 13 new tests across 5 test files

<details>
<summary>Implementation notes</summary>

**InteractiveGraphState union update.** Adding `TangentGraphState` to the union triggers `UnreachableCaseError` in `renderGraphElements` (mafs-graph.tsx). A placeholder case returns `null` (no rendering) — replaced with real rendering in PR 4.

**Same-x constraint.** The `doMovePoint` tangent case rejects moves that would place both control points on the same vertical line. This mirrors the sinusoid constraint and prevents `getTangentCoefficients` from producing `Infinity` for `angularFrequency` (since it divides by `p2[0] - p1[0]`).

**`getTangentCoords()` is not exported.** It's only called within `initialize-graph-state.ts`. It will be exported in PR 6 (editor) when `start-coords/util.ts` needs it.

**`mafsStateToInteractiveGraph` tangent case is the real implementation** (not a placeholder) — it simply returns `{ ...originalGraph, coords: state.coords }`, same as sinusoid.

</details>

### References
- [Tangent Notes](https://github.com/Khan/perseus/blob/main/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/tangent.md)
- POC: #3311

Co-Authored by Claude Code (Opus)

Issue: LEMS-3955

## Test plan:
- [ ] `pnpm tsc` — no type errors
- [ ] `pnpm lint` — no lint errors
- [ ] `pnpm prettier . --check` — formatting clean
- [ ] `pnpm knip` — no unused exports
- [ ] Initialization tests pass (given coords, startCoords, defaults)
- [ ] Reducer tests pass (same-x rejection, out-of-bounds rejection, valid move)
- [ ] `getGradableGraph` tangent test passes
- [ ] `mafsStateToInteractiveGraph` tangent serialization test passes
- [ ] Tangent renders in parameterized "should render" tests

Author: ivyolamit

Reviewers: claude[bot], handeyeco, ivyolamit, SonicScrewdriver

Required Reviewers:

Approved By: handeyeco

Checks: ✅ 10 checks were successful, ⏭️  1 check has been skipped

Pull Request URL: #3353
catandthemachines pushed a commit that referenced this pull request Apr 1, 2026
…tion string (#3354)

## Summary:

PR series to add tangent graph support to the Interactive Graph widget:

1. [Foundation — Add tangent graph type definitions and data schema](#3345)
2. [Math layer —  Add tangent math utilities to kmath](#3347)
3. [State management — Reducer, actions, initialization, and test data](#3353)
4. ▶️ [Rendering — The tangent graph component, Storybook story, and AI utils](#3354)
5. [Scoring — Add tangent scoring to the scoring package](#3356)
6. [Editor — Add tangent to answer type](#3358)

This is the fourth PR in a series to add tangent graph support to the Interactive Graph widget (LEMS-3955). It adds the rendering layer — the Mafs component, accessibility strings, equation string generation, and Storybook coverage.

---

Created the tangent graph visual component, add Storybook coverage, SR strings, and equation string for supporting Tangent graph in Interactive Graph.

- Adds the tangent graph visual component (`tangent.tsx`) with `renderTangentGraph()`, `computeTangent()`, keyboard constraints, and screen reader descriptions
- Adds 5 screen reader strings for tangent graph accessibility (`srTangentGraph`, `srTangentInflectionPoint`, `srTangentSecondPoint`, `srTangentDescription`, `srTangentInteractiveElements`)
- Replaces the `mafs-graph.tsx` placeholder with real `renderTangentGraph()` call
- Replaces the `interactive-graph.tsx` equation string placeholder with `getTangentEquationString()`
- Adds Tangent Storybook story
- 7 new tests for the tangent graph component

<details>
<summary>Implementation notes</summary>

**Tangent component follows the sinusoid pattern.** `tangent.tsx` mirrors `sinusoid.tsx` structurally: two movable control points, coefficient calculation with a ref-based fallback for invalid states, and the same keyboard constraint logic that prevents same-x points.

**Asymptote handling (vertical line bug fix).** Mafs `Plot.OfX` renders a single SVG `<path>` that draws vertical lines across discontinuities at asymptotes. To fix this, the tangent curve is split into segments between asymptotes:
- `getAsymptotePositions()` computes asymptote x-positions within the visible range: `x = (c + π/2 + nπ) / b`
- `getPlotSegments()` splits the x-range into segments between asymptotes with a small epsilon margin (0.01)
- Each segment is rendered as a separate `Plot.OfX` with a `domain` prop, so Mafs never draws across a discontinuity
- `computeTangent()` also returns NaN near asymptotes as a defensive backup. The proximity formula was corrected from the POC — the POC's `((arg / Math.PI + 0.5) % 1) - 0.5` measures distance from zero crossings (inflection points), not asymptotes. The corrected formula `((arg - Math.PI/2) / Math.PI) % 1` correctly targets asymptotes at `arg = π/2 + nπ`.
- This approach was validated in the POC (commit 204f3f2)

**Two `getTangentCoefficients` functions exist.** The one in `tangent.tsx` returns `NamedTangentCoefficient | undefined` (named object with `undefined` fallback for same-x points) for rendering use. The one in `kmath/coefficients.ts` returns `TangentCoefficient` (numeric tuple, returns `Infinity` for same-x) for scoring use. The UI prevents the same-x case via the reducer's same-x guard, so the difference only matters as a defensive measure.

**Screen reader descriptions.** The tangent graph uses "inflection point" for the first control point (where the curve crosses the midline) and "control point" for the second point (a quarter-period away). This differs from sinusoid which uses "midline intersection" and "maximum/minimum point" — tangent doesn't have a meaningful max/min since it approaches ±∞.

**Equation string.** `getTangentEquationString()` formats `y = a*tan(b*x - c) + d` using the same pattern as `getSinusoidEquationString()` but using `getTangentCoefficients` from kmath.

**No feature flag gate in rendering.** The tangent graph renders unconditionally once the graph type is set to "tangent". The feature flag gate is in the editor (PR 6), which controls whether content creators can select "tangent" as a graph type. This follows the existing pattern — no other graph types check feature flags at render time.

</details>

### References
- [Tangent Notes](https://github.com/Khan/perseus/blob/main/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/tangent.md)
- POC: #3311

Co-Authored by Claude Code (Opus)

Issue: LEMS-3955

## Test plan:
- [ ] `pnpm tsc` — no type errors
- [ ] `pnpm lint` — no lint errors
- [ ] `pnpm prettier . --check` — formatting clean
- [ ] `pnpm knip` — no unused exports
- [ ] SR tests pass (aria labels for graph, inflection point, control point, description, interactive elements)
- [ ] Coefficient calculation test passes
- [ ] Tangent computation test passes
- [ ] Invalid coefficient test passes (same-x returns undefined)
- [ ] Keyboard constraint test passes (avoids same-x)
- [ ] Tangent story renders in Storybook (`pnpm storybook`)

Author: ivyolamit

Reviewers: claude[bot], ivyolamit, handeyeco, SonicScrewdriver

Required Reviewers:

Approved By: handeyeco

Checks: ⏭️  1 check has been skipped, ✅ 10 checks were successful

Pull Request URL: #3354
catandthemachines pushed a commit that referenced this pull request Apr 1, 2026
## Summary:

PR series to add tangent graph support to the Interactive Graph widget:

1. [Foundation — Add tangent graph type definitions and data schema](#3345)
2. [Math layer —  Add tangent math utilities to kmath](#3347)
3. [State management — Reducer, actions, initialization, and test data](#3353)
4. [Rendering — The tangent graph component, Storybook story, and AI utils](#3354)
5. ▶️ [Scoring — Add tangent scoring to the scoring package](#3356)
6. [Editor — Add tangent to answer type](#3358)

This is the fifth PR in a series to add tangent graph support to the Interactive Graph widget (LEMS-3955). It adds the scoring layer — the final piece needed for tangent exercises to be fully functional (behind the feature flag).

---

Add tangent graph scoring to support the Tangent graph in Interactive Graph

- Adds tangent scoring to `scoreInteractiveGraph()` using `getTangentCoefficients` and `canonicalTangentCoefficients` from kmath
- Follows the sinusoid scoring pattern: extract coefficients from both guess and rubric, canonicalize, then compare
- 6 new tests covering invalid input, correct/incorrect answers, equivalent curves, and negative amplitude

<details>
<summary>Implementation notes</summary>

**Scoring follows the sinusoid pattern exactly.** The tangent scoring block extracts coefficients from both the user's guess and the rubric's correct answer using `getTangentCoefficients()` (from kmath), canonicalizes both with `canonicalTangentCoefficients()`, and compares with `approximateDeepEqual()`. This handles equivalent curves that use different control points (e.g., shifted by a full period).

**Uses kmath's `canonicalTangentCoefficients`, NOT the legacy grapher-util version.** The kmath version (PR 2) only guarantees `b > 0`, which is mathematically correct for tangent. The legacy version guarantees both `a > 0` and `b > 0` using a mathematically incorrect phase shift. See PR 2 implementation notes for details.

</details>

### References
- [Tangent Notes](https://github.com/Khan/perseus/blob/main/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/tangent.md)
- POC: #3311

Co-Authored by Claude Code (Opus)

Issue: LEMS-3955

## Test plan:
- [ ] `pnpm tsc` — no type errors
- [ ] `pnpm lint` — no lint errors
- [ ] `pnpm prettier . --check` — formatting clean
- [ ] `pnpm knip` — no unused exports
- [ ] Invalid input tests pass (undefined guess, missing coords)
- [ ] Correct answer test passes
- [ ] Incorrect answer test passes
- [ ] Equivalent curves test passes (period-shifted control points)
- [ ] Negative amplitude test passes

Author: ivyolamit

Reviewers: claude[bot], handeyeco, SonicScrewdriver

Required Reviewers:

Approved By: handeyeco

Checks: ⏭️  1 check has been skipped, ✅ 10 checks were successful

Pull Request URL: #3356
catandthemachines pushed a commit that referenced this pull request Apr 1, 2026
… Editor (#3358)

## Summary:

PR series to add tangent graph support to the Interactive Graph widget:

1. [Foundation — Add tangent graph type definitions and data schema](#3345)
2. [Math layer —  Add tangent math utilities to kmath](#3347)
3. [State management — Reducer, actions, initialization, and test data](#3353)
4. [Rendering — The tangent graph component, Storybook story, and AI utils](#3354)
5. [Scoring — Add tangent scoring to the scoring package](#3356)
6. ▶️ [Editor — Add tangent to answer type](#3358)

This is the sixth and final PR in a series to add tangent graph support to the Interactive Graph widget (LEMS-3956). It adds editor support so content creators can create tangent exercises.

---

Add tangent graph option in the Interactive Graph Editor

- Adds tangent as a selectable graph type in the editor, gated by the `interactive-graph-tangent` feature flag
- Adds `StartCoordsTangent` component for configuring tangent start coordinates (inflection point + quarter-period point)
- Adds tangent equation display in the start coordinates editor section
- Exports `getTangentCoords()` from perseus for use by the editor's start-coords utilities
- 0 new tests (existing parameterized tests cover the new code paths)

<details>
<summary>Implementation notes</summary>

**Feature flag gating.** The tangent `OptionItem` in `GraphTypeSelector` only renders when `isFeatureOn("interactive-graph-tangent")` is true. This is the only place the feature flag is checked — once a tangent graph type is persisted in content JSON, it renders and scores regardless of the flag. This follows the pattern used by other gated features (e.g., `image-widget-upgrade-scale`).

**`apiOptions` prop threading.** `GraphTypeSelector` needed access to `apiOptions` for `isFeatureOn`. Added `apiOptions` as an optional prop and threaded it from `InteractiveGraphEditor`.

**`getTangentCoords()` export.** This function was internal to `initialize-graph-state.ts` (as noted in PR 3's plan). Now exported and re-exported from perseus index, used by `getDefaultGraphStartCoords` and `StartCoordsSettingsInner` in the editor.

**`StartCoordsTangent` mirrors `StartCoordsSinusoid` exactly.** Two coordinate pair inputs (Point 1 / Point 2) and an equation display using `getTangentEquation()`. The equation helper follows the same pattern as `getSinusoidEquation()`.

**`shouldShowStartCoordsUI` flipped.** Changed from `false` (set in PR 1 as a placeholder) to `true` for tangent, now that `StartCoordsSettingsInner` and `getDefaultGraphStartCoords` both handle the tangent type.

</details>

### References
- [Tangent Notes](https://github.com/Khan/perseus/blob/main/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/tangent.md)
- POC: #3311

Co-Authored by Claude Code (Opus)

Issue: LEMS-3956

## Test plan:
- [ ] `pnpm tsc` — no type errors
- [ ] `pnpm lint` — no lint errors
- [ ] `pnpm prettier . --check` — formatting clean
- [ ] `pnpm knip` — no unused exports
- [ ] Tangent option appears in graph type dropdown when feature flag is on
- [ ] Tangent option does NOT appear when feature flag is off
- [ ] Start coordinates section appears for tangent graph type
- [ ] Start coordinates reset button works for tangent
- [ ] Tangent equation updates when start coordinates change
- [ ] Existing editor tests pass (422 tests)

Author: ivyolamit

Reviewers: claude[bot], ivyolamit, handeyeco, SonicScrewdriver

Required Reviewers:

Approved By: SonicScrewdriver

Checks: ⏭️  1 check has been skipped, ✅ 10 checks were successful

Pull Request URL: #3358
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants