Skip to content

[Interactive Graph] Add showAxisArrows field to Interactive Graph widget #2777

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

nishasy
Copy link
Contributor

@nishasy nishasy commented Aug 7, 2025

Summary:

Working on adding a way for content authors to specify which axis arrows to remove
for graphs that need to be bounded in some direction. This can be any combination
of x min, x max, y min, and y max directions.

In this PR, I'm adding a showAxisArrows field to the Interactive Graph schema, and
I'm connecting it to the widget. The editor updates will be in a following PR.

Note:

  • As of 8/7/25 afternoon, I haven't deployed the backend yet. I will wait for that to finish + publish worker update before I can start the release process for this.
  • Backend PR for adding showAxisArrows to widgets.go

Issue: https://khanacademy.atlassian.net/browse/LEMS-3214

Screenshots:

All None
Screenshot 2025-08-07 at 3 11 24 PM Screenshot 2025-08-07 at 3 11 27 PM
xMax xMin
Screenshot 2025-08-07 at 3 11 30 PM Screenshot 2025-08-07 at 3 11 33 PM
yMax yMin
Screenshot 2025-08-07 at 3 11 37 PM Screenshot 2025-08-07 at 3 11 40 PM

Test plan:

pnpm jest packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.test.ts
pnpm jest packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx

Check this PR for the new regression tests, check future PRs for regression failures

Storybook

nishasy added 4 commits August 7, 2025 13:59
…`boundedSides` field

Working on adding a way for content authors to specify which axis arrows to remove
for graphs that need to be bounded in some direction. This can be any combination
of x min, x max, y min, and y max directions.

In this PR, I'm adding a `boundedSides` field to the Interactive Graph schema, and
I'm connecting it to the widget. The editor updates will be in a following PR.

Issue: https://khanacademy.atlassian.net/browse/LEMS-3214

Test plan:
`pnpm jest packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.test.ts`
Check this PR for the new regression tests, check future PRs for regression failures

Storybook
- http://localhost:6006/?path=/story/perseus-widgets-interactive-graph-visual-regression-tests--bounded-x-min
- http://localhost:6006/?path=/story/perseus-widgets-interactive-graph-visual-regression-tests--bounded-x-max
- http://localhost:6006/?path=/story/perseus-widgets-interactive-graph-visual-regression-tests--bounded-y-min
- http://localhost:6006/?path=/story/perseus-widgets-interactive-graph-visual-regression-tests--bounded-y-max
- http://localhost:6006/?path=/story/perseus-widgets-interactive-graph-visual-regression-tests--bounded-all-sides
@nishasy nishasy self-assigned this Aug 7, 2025
@github-actions github-actions bot added the schema-change Attached to PRs when we detect Perseus Schema changes in it label Aug 7, 2025
Copy link
Contributor

github-actions bot commented Aug 7, 2025

🗄️ Schema Change: Changes Detected ⚠️

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	2025-08-07 22:50:18.283055242 +0000
+++ /home/runner/work/_temp/branch-compare/pr/schema.d.ts	2025-08-07 22:50:06.146946235 +0000
@@ -158,6 +158,12 @@
   export type Size = [width: number, height: number];
   export type CollinearTuple = [Vector2, Vector2];
   export type ShowSolutions = "all" | "selected" | "none";
+  export type ShowAxisArrows = {
+    xMin: boolean;
+    xMax: boolean;
+    yMin: boolean;
+    yMax: boolean;
+  };
   /**
    * A utility type that constructs a widget map from a "registry interface".
    * The keys of the registry should be the widget type (aka, "categorizer" or
@@ -726,6 +732,7 @@
      *    The y label is rotated. Typically used when the range min is near 0 with longer labels.
      */
     labelLocation?: AxisLabelLocation;
+    showAxisArrows: ShowAxisArrows;
     showProtractor: boolean;
     /**
      * Whether to show the Ruler tool overlayed on top of the graph.

Copy link
Contributor

github-actions bot commented Aug 7, 2025

🛠️ Item Splitting: Changes Detected ⚠️

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	2025-08-07 22:50:54.736574740 +0000
+++ /home/runner/work/_temp/branch-compare/pr/index.item-splitting.js	2025-08-07 22:50:26.716169554 +0000
@@ -104,7 +104,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),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 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 parseLabelImageWidget=parseWidget(constant("label-image"),object({choices:array(string),imageUrl:string,imageAlt:string,imageHeight:number,imageWidth:number,markers:array(object({answers:array(string),label:string,x:number,y:number})),hideChoicesFromInstructions:boolean,multipleAnswers:boolean,static:defaulted(boolean,()=>false)}));
 

Copy link
Contributor

github-actions bot commented Aug 7, 2025

Size Change: +189 B (+0.04%)

Total Size: 496 kB

Filename Size Change
packages/perseus-core/dist/es/index.item-splitting.js 12.9 kB +48 B (+0.37%)
packages/perseus-core/dist/es/index.js 21.5 kB +45 B (+0.21%)
packages/perseus/dist/es/index.js 207 kB +96 B (+0.05%)
ℹ️ View Unchanged
Filename Size
packages/kas/dist/es/index.js 20.7 kB
packages/keypad-context/dist/es/index.js 1 kB
packages/kmath/dist/es/index.js 5.98 kB
packages/math-input/dist/es/index.js 98.6 kB
packages/math-input/dist/es/strings.js 1.61 kB
packages/perseus-editor/dist/es/index.js 94.1 kB
packages/perseus-linter/dist/es/index.js 7.07 kB
packages/perseus-score/dist/es/index.js 9.34 kB
packages/perseus-utils/dist/es/index.js 403 B
packages/perseus/dist/es/strings.js 7.56 kB
packages/pure-markdown/dist/es/index.js 1.39 kB
packages/simple-markdown/dist/es/index.js 6.71 kB

compressed-size-action

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This file has the main logic.

@nishasy nishasy requested review from benchristel, SonicScrewdriver and a team and removed request for benchristel August 7, 2025 22:18
Copy link
Member

@benchristel benchristel left a comment

Choose a reason for hiding this comment

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

This is looking good! I'll hold off on approval until you mark it "ready for review" though.

} = useGraphConfig();

const axisColor = "var(--mafs-fg)";

// Arrow defaults to true if not specified.
const arrows: ShowAxisArrows =
showAxisArrows === undefined
Copy link
Member

Choose a reason for hiding this comment

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

Since we default showAxisArrows in the parser, why would it be undefined?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, that's left over from before.

Copy link
Contributor

github-actions bot commented Aug 7, 2025

npm Snapshot: Published

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

Example:

pnpm add @khanacademy/perseus@PR2777

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

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

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

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

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