Skip to content

[Interactive Graph] POC Add tangent graph in Interactive Graph widget#3311

Closed
ivyolamit wants to merge 5 commits intomainfrom
LEMS-3937/poc-interactive-tangent-graph
Closed

[Interactive Graph] POC Add tangent graph in Interactive Graph widget#3311
ivyolamit wants to merge 5 commits intomainfrom
LEMS-3937/poc-interactive-tangent-graph

Conversation

@ivyolamit
Copy link
Copy Markdown
Contributor

@ivyolamit ivyolamit commented Mar 6, 2026

Summary:

POC Add tangent graph in Interactive Graph widget
This adds the tangent graph (f(x) = a·tan(bx - c) + d) as a new answer type in the Interactive Graph widget, bringing it from the legacy Grapher widget into the modern Mafs-based rendering system.

  • Implements the full tangent graph type end-to-end: rendering, interaction, scoring, editor support, accessibility, and i18n strings
  • Follows the existing sinusoid implementation as the pattern, with adjustments for tangent-specific math (period = π vs 2π, asymptotes at bx - c = π/2 + nπ)
  • Splits the tangent curve into separate Plot.OfX segments between vertical asymptotes using the Mafs domain prop, which creates independent SVG paths and prevents rendering artifacts (vertical lines drawn across discontinuities)

Solution approach
Coefficient derivation from two control points:
The tangent curve is defined by two interactive points. Given points P1 and P2:

  • amplitude (a) = P2.y - P1.y
  • angularFrequency (b) = π / (4·Δx) — note: sinusoid uses π/(2·Δx)
  • phase (c) = P1.x · b
  • verticalOffset (d) = P1.y

Asymptote handling:
Tangent functions have vertical asymptotes where tan() is undefined. Mafs Plot.OfX renders a single continuous SVG path, so naively plotting tan(x) produces vertical lines connecting +∞ to -∞. The solution:

  1. Compute asymptote x-positions within the visible range
  2. Split the x-domain into segments between asymptotes (with a small epsilon gap)
  3. Render each segment as a separate Plot.OfX with its own domain prop, producing independent SVG elements
  4. Additionally return NaN near asymptotes as a safety net to break any remaining path continuity

Scoring:
Uses canonical coefficient normalization (ensuring a > 0, b > 0, c ∈ [0, π)) to compare student answers with the correct answer, matching the pattern used by sinusoid scoring.

Packages changed

Packages Changes
perseus Tangent graph component, reducer actions, state initialization, rendering, accessibility strings, test data, Storybook story
perseus-core PerseusGraphTypeTangent type definition, JSON parser
perseus-editor Graph type selector dropdown, editor support, start coords
perseus-score Tangent scoring with canonical coefficient comparison
kmath canonicalTangentCoefficients, getTangentCoefficients utilities

Test Plan

  • Open Storybook → Widgets/Interactive Graph → Tangent story
  • Verify tangent curve renders correctly with no vertical lines at asymptotes
  • Drag both control points and confirm the curve updates smoothly
  • Use keyboard navigation on both points (arrow keys)
  • Verify screen reader announces graph description and point positions
  • Open Interactive Graph editor → confirm "Tangent function" appears in the answer type dropdown
  • Select tangent type in editor, set correct answer, and verify scoring works
  • Move points so they share the same x-coordinate — verify graceful handling (no crash)
  • Run pnpm --filter perseus test and pnpm --filter perseus-score test
  • Run pnpm tsc for type checking across all packages

Issue: LEMS-3937

Co-Authored by Claude (Opus)

@ivyolamit ivyolamit self-assigned this Mar 6, 2026
@github-actions github-actions bot added the schema-change Attached to PRs when we detect Perseus Schema changes in it label Mar 6, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 6, 2026

🗄️ Schema Change: Changes Detected ⚠️

Usually this means you need to update the Go parser
that Content Platform maintains!!!

See this list of post-mortems for more information.

This PR contains critical changes to Perseus. Please review
the changes and note that you may need to coordinate
deployment of these changes with other teams at Khan Academy.

diff --unified /home/runner/work/_temp/branch-compare/base/schema.d.ts /home/runner/work/_temp/branch-compare/pr/schema.d.ts
--- /home/runner/work/_temp/branch-compare/base/schema.d.ts	2026-03-12 00:57:33.185099349 +0000
+++ /home/runner/work/_temp/branch-compare/pr/schema.d.ts	2026-03-12 00:57:21.203001183 +0000
@@ -879,7 +879,8 @@
     | PerseusGraphTypeQuadratic
     | PerseusGraphTypeRay
     | PerseusGraphTypeSegment
-    | PerseusGraphTypeSinusoid;
+    | PerseusGraphTypeSinusoid
+    | PerseusGraphTypeTangent;
   export type PerseusGraphTypeAngle = {
     type: "angle";
     showAngles?: boolean;
@@ -945,6 +946,11 @@
     coords?: Coord[] | null;
     startCoords?: Coord[];
   };
+  export type PerseusGraphTypeTangent = {
+    type: "tangent";
+    coords?: Coord[] | null;
+    startCoords?: Coord[];
+  };
   export type PerseusGraphTypeRay = {
     type: "ray";
     coords?: CollinearTuple | null;
@@ -993,6 +999,10 @@
     type: "sinusoid";
     coords: CollinearTuple;
   };
+  type TangentGraphCorrect = {
+    type: "tangent";
+    coords: CollinearTuple;
+  };
   type RayGraphCorrect = {
     type: "ray";
     coords: CollinearTuple;
@@ -1008,7 +1018,8 @@
     | QuadraticGraphCorrect
     | RayGraphCorrect
     | SegmentGraphCorrect
-    | SinusoidGraphCorrect;
+    | SinusoidGraphCorrect
+    | TangentGraphCorrect;
   export type PerseusLabelImageWidgetOptions = {
     choices: string[];
     imageUrl: string;

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 6, 2026

Size Change: +1.2 kB (+0.25%)

Total Size: 487 kB

Filename Size Change
packages/kmath/dist/es/index.js 6.01 kB +51 B (+0.86%)
packages/perseus-core/dist/es/index.item-splitting.js 11.9 kB +19 B (+0.16%)
packages/perseus-core/dist/es/index.js 24.9 kB +23 B (+0.09%)
packages/perseus-editor/dist/es/index.js 100 kB +30 B (+0.03%)
packages/perseus-score/dist/es/index.js 9.3 kB +45 B (+0.49%)
packages/perseus/dist/es/index.js 188 kB +886 B (+0.47%)
packages/perseus/dist/es/strings.js 7.62 kB +146 B (+1.95%)
ℹ️ View Unchanged
Filename Size
packages/kas/dist/es/index.js 20.8 kB
packages/keypad-context/dist/es/index.js 1 kB
packages/math-input/dist/es/index.js 98.5 kB
packages/math-input/dist/es/strings.js 1.61 kB
packages/perseus-linter/dist/es/index.js 8.82 kB
packages/perseus-utils/dist/es/index.js 403 B
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 6, 2026

🛠️ Item Splitting: Changes Detected ⚠️

Usually this means you need to update the Go parser
that Content Platform maintains!!!

See this list of post-mortems for more information.

This PR contains critical changes to Perseus. Please review
the changes and note that you may need to coordinate
deployment of these changes with other teams at Khan Academy.

diff --unified /home/runner/work/_temp/branch-compare/base/index.item-splitting.js /home/runner/work/_temp/branch-compare/pr/index.item-splitting.js
--- /home/runner/work/_temp/branch-compare/base/index.item-splitting.js	2026-03-12 00:57:54.825221910 +0000
+++ /home/runner/work/_temp/branch-compare/pr/index.item-splitting.js	2026-03-12 00:57:33.701893927 +0000
@@ -100,7 +100,7 @@
 
 const lockedFigureColorNames=["blue","green","grayH","purple","pink","orange","red"];const plotterPlotTypes=["bar","line","pic","histogram","dotplot"];
 
-const pairOfNumbers=pair(number,number);const parsePerseusGraphTypeAngle=object({type:constant("angle"),showAngles:optional(boolean),allowReflexAngles:optional(boolean),angleOffsetDeg:optional(number),snapDegrees:optional(number),match:optional(constant("congruent")),coords:optional(trio(pairOfNumbers,pairOfNumbers,pairOfNumbers)),startCoords:optional(trio(pairOfNumbers,pairOfNumbers,pairOfNumbers))});const parsePerseusGraphTypeCircle=object({type:constant("circle"),center:optional(pairOfNumbers),radius:optional(number),startCoords:optional(object({center:pairOfNumbers,radius:number}))});const parsePerseusGraphTypeLinear=object({type:constant("linear"),coords:optional(nullable(pair(pairOfNumbers,pairOfNumbers))),startCoords:optional(pair(pairOfNumbers,pairOfNumbers))});const parsePerseusGraphTypeLinearSystem=object({type:constant("linear-system"),coords:optional(nullable(array(pair(pairOfNumbers,pairOfNumbers)))),startCoords:optional(array(pair(pairOfNumbers,pairOfNumbers)))});const parsePerseusGraphTypeNone=object({type:constant("none")});const parsePerseusGraphTypePoint=object({type:constant("point"),numPoints:optional(union(number).or(constant("unlimited")).parser),coords:optional(nullable(array(pairOfNumbers))),startCoords:optional(array(pairOfNumbers)),coord:optional(pairOfNumbers)});const parsePerseusGraphTypePolygon=object({type:constant("polygon"),numSides:optional(union(number).or(constant("unlimited")).parser),showAngles:optional(boolean),showSides:optional(boolean),snapTo:optional(enumeration("grid","angles","sides")),match:optional(enumeration("similar","congruent","approx","exact")),startCoords:optional(array(pairOfNumbers)),coords:optional(nullable(array(pairOfNumbers)))});const parsePerseusGraphTypeQuadratic=object({type:constant("quadratic"),coords:optional(nullable(trio(pairOfNumbers,pairOfNumbers,pairOfNumbers))),startCoords:optional(trio(pairOfNumbers,pairOfNumbers,pairOfNumbers))});const parsePerseusGraphTypeRay=object({type:constant("ray"),coords:optional(nullable(pair(pairOfNumbers,pairOfNumbers))),startCoords:optional(pair(pairOfNumbers,pairOfNumbers))});const parsePerseusGraphTypeSegment=object({type:constant("segment"),numSegments:optional(number),coords:optional(nullable(array(pair(pairOfNumbers,pairOfNumbers)))),startCoords:optional(array(pair(pairOfNumbers,pairOfNumbers)))});const parsePerseusGraphTypeSinusoid=object({type:constant("sinusoid"),coords:optional(nullable(array(pairOfNumbers))),startCoords:optional(array(pairOfNumbers))});const parsePerseusGraphType=discriminatedUnionOn("type").withBranch("angle",parsePerseusGraphTypeAngle).withBranch("circle",parsePerseusGraphTypeCircle).withBranch("linear",parsePerseusGraphTypeLinear).withBranch("linear-system",parsePerseusGraphTypeLinearSystem).withBranch("none",parsePerseusGraphTypeNone).withBranch("point",parsePerseusGraphTypePoint).withBranch("polygon",parsePerseusGraphTypePolygon).withBranch("quadratic",parsePerseusGraphTypeQuadratic).withBranch("ray",parsePerseusGraphTypeRay).withBranch("segment",parsePerseusGraphTypeSegment).withBranch("sinusoid",parsePerseusGraphTypeSinusoid).parser;const parseLockedFigureColor=enumeration(...lockedFigureColorNames);const parseLockedFigureFillType=enumeration("none","white","translucent","solid");const parseLockedLineStyle=enumeration("solid","dashed");const parseStrokeWeight=defaulted(enumeration("medium","thin","thick"),()=>"medium");const parseLockedLabelType=object({type:constant("label"),coord:pairOfNumbers,text:string,color:parseLockedFigureColor,size:enumeration("small","medium","large")});const parseLockedPointType=object({type:constant("point"),coord:pairOfNumbers,color:parseLockedFigureColor,filled:boolean,labels:defaulted(array(parseLockedLabelType),()=>[]),ariaLabel:optional(string)});const parseLockedLineType=object({type:constant("line"),kind:enumeration("line","ray","segment"),points:pair(parseLockedPointType,parseLockedPointType),color:parseLockedFigureColor,lineStyle:parseLockedLineStyle,showPoint1:defaulted(boolean,()=>false),showPoint2:defaulted(boolean,()=>false),weight:parseStrokeWeight,labels:defaulted(array(parseLockedLabelType),()=>[]),ariaLabel:optional(string)});const parseLockedVectorType=object({type:constant("vector"),points:pair(pairOfNumbers,pairOfNumbers),color:parseLockedFigureColor,weight:parseStrokeWeight,labels:defaulted(array(parseLockedLabelType),()=>[]),ariaLabel:optional(string)});const parseLockedEllipseType=object({type:constant("ellipse"),center:pairOfNumbers,radius:pairOfNumbers,angle:number,color:parseLockedFigureColor,fillStyle:parseLockedFigureFillType,strokeStyle:parseLockedLineStyle,weight:parseStrokeWeight,labels:defaulted(array(parseLockedLabelType),()=>[]),ariaLabel:optional(string)});const parseLockedPolygonType=object({type:constant("polygon"),points:array(pairOfNumbers),color:parseLockedFigureColor,showVertices:boolean,fillStyle:parseLockedFigureFillType,strokeStyle:parseLockedLineStyle,weight:parseStrokeWeight,labels:defaulted(array(parseLockedLabelType),()=>[]),ariaLabel:optional(string)});const parseLockedFunctionDomain=defaulted(pair(defaulted(number,()=>-Infinity),defaulted(number,()=>Infinity)),()=>[-Infinity,Infinity]);const parseLockedFunctionType=object({type:constant("function"),color:parseLockedFigureColor,strokeStyle:parseLockedLineStyle,weight:parseStrokeWeight,equation:string,directionalAxis:enumeration("x","y"),domain:parseLockedFunctionDomain,labels:defaulted(array(parseLockedLabelType),()=>[]),ariaLabel:optional(string)});const parseLockedFigure=discriminatedUnionOn("type").withBranch("point",parseLockedPointType).withBranch("line",parseLockedLineType).withBranch("vector",parseLockedVectorType).withBranch("ellipse",parseLockedEllipseType).withBranch("polygon",parseLockedPolygonType).withBranch("function",parseLockedFunctionType).withBranch("label",parseLockedLabelType).parser;const parseLabelLocation=union(enumeration("onAxis","alongEdge")).or(pipeParsers(constant("")).then(convert(()=>"onAxis")).parser).parser;const parseInteractiveGraphWidget=parseWidget(constant("interactive-graph"),object({step:pairOfNumbers,gridStep:optional(pairOfNumbers),snapStep:optional(pairOfNumbers),backgroundImage:optional(parsePerseusImageBackground),markings:enumeration("graph","grid","none","axes"),labels:optional(array(string)),labelLocation:optional(parseLabelLocation),showProtractor:boolean,showRuler:optional(boolean),showTooltips:optional(boolean),rulerLabel:optional(string),rulerTicks:optional(number),range:pair(pairOfNumbers,pairOfNumbers),showAxisArrows:defaulted(object({xMin:boolean,xMax:boolean,yMin:boolean,yMax:boolean}),()=>({xMin:true,xMax:true,yMin:true,yMax:true})),graph:defaulted(parsePerseusGraphType,()=>({type:"linear"})),correct:defaulted(parsePerseusGraphType,()=>({type:"linear"})),lockedFigures:defaulted(array(parseLockedFigure),()=>[]),fullGraphAriaLabel:optional(string),fullGraphAriaDescription:optional(string)}));
+const pairOfNumbers=pair(number,number);const parsePerseusGraphTypeAngle=object({type:constant("angle"),showAngles:optional(boolean),allowReflexAngles:optional(boolean),angleOffsetDeg:optional(number),snapDegrees:optional(number),match:optional(constant("congruent")),coords:optional(trio(pairOfNumbers,pairOfNumbers,pairOfNumbers)),startCoords:optional(trio(pairOfNumbers,pairOfNumbers,pairOfNumbers))});const parsePerseusGraphTypeCircle=object({type:constant("circle"),center:optional(pairOfNumbers),radius:optional(number),startCoords:optional(object({center:pairOfNumbers,radius:number}))});const parsePerseusGraphTypeLinear=object({type:constant("linear"),coords:optional(nullable(pair(pairOfNumbers,pairOfNumbers))),startCoords:optional(pair(pairOfNumbers,pairOfNumbers))});const parsePerseusGraphTypeLinearSystem=object({type:constant("linear-system"),coords:optional(nullable(array(pair(pairOfNumbers,pairOfNumbers)))),startCoords:optional(array(pair(pairOfNumbers,pairOfNumbers)))});const parsePerseusGraphTypeNone=object({type:constant("none")});const parsePerseusGraphTypePoint=object({type:constant("point"),numPoints:optional(union(number).or(constant("unlimited")).parser),coords:optional(nullable(array(pairOfNumbers))),startCoords:optional(array(pairOfNumbers)),coord:optional(pairOfNumbers)});const parsePerseusGraphTypePolygon=object({type:constant("polygon"),numSides:optional(union(number).or(constant("unlimited")).parser),showAngles:optional(boolean),showSides:optional(boolean),snapTo:optional(enumeration("grid","angles","sides")),match:optional(enumeration("similar","congruent","approx","exact")),startCoords:optional(array(pairOfNumbers)),coords:optional(nullable(array(pairOfNumbers)))});const parsePerseusGraphTypeQuadratic=object({type:constant("quadratic"),coords:optional(nullable(trio(pairOfNumbers,pairOfNumbers,pairOfNumbers))),startCoords:optional(trio(pairOfNumbers,pairOfNumbers,pairOfNumbers))});const parsePerseusGraphTypeRay=object({type:constant("ray"),coords:optional(nullable(pair(pairOfNumbers,pairOfNumbers))),startCoords:optional(pair(pairOfNumbers,pairOfNumbers))});const parsePerseusGraphTypeSegment=object({type:constant("segment"),numSegments:optional(number),coords:optional(nullable(array(pair(pairOfNumbers,pairOfNumbers)))),startCoords:optional(array(pair(pairOfNumbers,pairOfNumbers)))});const parsePerseusGraphTypeSinusoid=object({type:constant("sinusoid"),coords:optional(nullable(array(pairOfNumbers))),startCoords:optional(array(pairOfNumbers))});const parsePerseusGraphTypeTangent=object({type:constant("tangent"),coords:optional(nullable(array(pairOfNumbers))),startCoords:optional(array(pairOfNumbers))});const parsePerseusGraphType=discriminatedUnionOn("type").withBranch("angle",parsePerseusGraphTypeAngle).withBranch("circle",parsePerseusGraphTypeCircle).withBranch("linear",parsePerseusGraphTypeLinear).withBranch("linear-system",parsePerseusGraphTypeLinearSystem).withBranch("none",parsePerseusGraphTypeNone).withBranch("point",parsePerseusGraphTypePoint).withBranch("polygon",parsePerseusGraphTypePolygon).withBranch("quadratic",parsePerseusGraphTypeQuadratic).withBranch("ray",parsePerseusGraphTypeRay).withBranch("segment",parsePerseusGraphTypeSegment).withBranch("sinusoid",parsePerseusGraphTypeSinusoid).withBranch("tangent",parsePerseusGraphTypeTangent).parser;const parseLockedFigureColor=enumeration(...lockedFigureColorNames);const parseLockedFigureFillType=enumeration("none","white","translucent","solid");const parseLockedLineStyle=enumeration("solid","dashed");const parseStrokeWeight=defaulted(enumeration("medium","thin","thick"),()=>"medium");const parseLockedLabelType=object({type:constant("label"),coord:pairOfNumbers,text:string,color:parseLockedFigureColor,size:enumeration("small","medium","large")});const parseLockedPointType=object({type:constant("point"),coord:pairOfNumbers,color:parseLockedFigureColor,filled:boolean,labels:defaulted(array(parseLockedLabelType),()=>[]),ariaLabel:optional(string)});const parseLockedLineType=object({type:constant("line"),kind:enumeration("line","ray","segment"),points:pair(parseLockedPointType,parseLockedPointType),color:parseLockedFigureColor,lineStyle:parseLockedLineStyle,showPoint1:defaulted(boolean,()=>false),showPoint2:defaulted(boolean,()=>false),weight:parseStrokeWeight,labels:defaulted(array(parseLockedLabelType),()=>[]),ariaLabel:optional(string)});const parseLockedVectorType=object({type:constant("vector"),points:pair(pairOfNumbers,pairOfNumbers),color:parseLockedFigureColor,weight:parseStrokeWeight,labels:defaulted(array(parseLockedLabelType),()=>[]),ariaLabel:optional(string)});const parseLockedEllipseType=object({type:constant("ellipse"),center:pairOfNumbers,radius:pairOfNumbers,angle:number,color:parseLockedFigureColor,fillStyle:parseLockedFigureFillType,strokeStyle:parseLockedLineStyle,weight:parseStrokeWeight,labels:defaulted(array(parseLockedLabelType),()=>[]),ariaLabel:optional(string)});const parseLockedPolygonType=object({type:constant("polygon"),points:array(pairOfNumbers),color:parseLockedFigureColor,showVertices:boolean,fillStyle:parseLockedFigureFillType,strokeStyle:parseLockedLineStyle,weight:parseStrokeWeight,labels:defaulted(array(parseLockedLabelType),()=>[]),ariaLabel:optional(string)});const parseLockedFunctionDomain=defaulted(pair(defaulted(number,()=>-Infinity),defaulted(number,()=>Infinity)),()=>[-Infinity,Infinity]);const parseLockedFunctionType=object({type:constant("function"),color:parseLockedFigureColor,strokeStyle:parseLockedLineStyle,weight:parseStrokeWeight,equation:string,directionalAxis:enumeration("x","y"),domain:parseLockedFunctionDomain,labels:defaulted(array(parseLockedLabelType),()=>[]),ariaLabel:optional(string)});const parseLockedFigure=discriminatedUnionOn("type").withBranch("point",parseLockedPointType).withBranch("line",parseLockedLineType).withBranch("vector",parseLockedVectorType).withBranch("ellipse",parseLockedEllipseType).withBranch("polygon",parseLockedPolygonType).withBranch("function",parseLockedFunctionType).withBranch("label",parseLockedLabelType).parser;const parseLabelLocation=union(enumeration("onAxis","alongEdge")).or(pipeParsers(constant("")).then(convert(()=>"onAxis")).parser).parser;const parseInteractiveGraphWidget=parseWidget(constant("interactive-graph"),object({step:pairOfNumbers,gridStep:optional(pairOfNumbers),snapStep:optional(pairOfNumbers),backgroundImage:optional(parsePerseusImageBackground),markings:enumeration("graph","grid","none","axes"),labels:optional(array(string)),labelLocation:optional(parseLabelLocation),showProtractor:boolean,showRuler:optional(boolean),showTooltips:optional(boolean),rulerLabel:optional(string),rulerTicks:optional(number),range:pair(pairOfNumbers,pairOfNumbers),showAxisArrows:defaulted(object({xMin:boolean,xMax:boolean,yMin:boolean,yMax:boolean}),()=>({xMin:true,xMax:true,yMin:true,yMax:true})),graph:defaulted(parsePerseusGraphType,()=>({type:"linear"})),correct:defaulted(parsePerseusGraphType,()=>({type:"linear"})),lockedFigures:defaulted(array(parseLockedFigure),()=>[]),fullGraphAriaLabel:optional(string),fullGraphAriaDescription:optional(string)}));
 
 const parseLabelImageWidget=parseWidget(constant("label-image"),object({choices:array(string),imageUrl:string,imageAlt:string,imageHeight:number,imageWidth:number,markers:array(object({answers:defaulted(array(string),()=>[]),label:string,x:number,y:number})),hideChoicesFromInstructions:boolean,multipleAnswers:boolean,static:defaulted(boolean,()=>false)}));
 

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 6, 2026

npm Snapshot: Published

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

Example:

pnpm add @khanacademy/perseus@PR3311

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

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

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

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

@ivyolamit
Copy link
Copy Markdown
Contributor Author

Screenshot:

image

@@ -0,0 +1,9 @@
---
"@khanacademy/kmath": minor
"@khanacademy/perseus": minor
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.

the changes should be considered as major since this is adding a new feature supporting the tangent graph as a new answer type in the interactive graph widget.

coeffRef.current = coeffs;
}

// WORKAROUND for Mafs discontinuity rendering — see getPlotSegments().
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.

Suggested change
// WORKAROUND for Mafs discontinuity rendering — see getPlotSegments().
// TODO: LEMS-2262 WORKAROUND for Mafs discontinuity rendering — see getPlotSegments().

return asymptotes.sort((a, b) => a - b);
}

// WORKAROUND: Mafs Plot.OfX renders a single SVG <path> and skips
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.

Suggested change
// WORKAROUND: Mafs Plot.OfX renders a single SVG <path> and skips
// TODO: LEMS-2262: Mafs Plot.OfX renders a single SVG <path> and skips

@ivyolamit ivyolamit force-pushed the LEMS-3937/poc-interactive-tangent-graph branch from fe4d463 to 797956e Compare March 10, 2026 23:33
…karound comment for Mafs discontinuity rendering

Add upstream issue link and removal instructions for the tangent graph asymptote workaround.
@ivyolamit ivyolamit force-pushed the LEMS-3937/poc-interactive-tangent-graph branch from 797956e to 6e75ff6 Compare March 11, 2026 20:25
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
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 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
ivyolamit added a commit that referenced this pull request Mar 19, 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
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
@ivyolamit ivyolamit closed this Apr 8, 2026
@ivyolamit ivyolamit deleted the LEMS-3937/poc-interactive-tangent-graph branch April 8, 2026 16:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

item-splitting-change olc-5.0.97b91 schema-change Attached to PRs when we detect Perseus Schema changes in it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants