Skip to content

[Interactive Graph] POC Add logarithm graph in Interactive Graph widget#3322

Draft
ivyolamit wants to merge 8 commits intomainfrom
LEMS-3950/poc-logarithm-interactive-graph
Draft

[Interactive Graph] POC Add logarithm graph in Interactive Graph widget#3322
ivyolamit wants to merge 8 commits intomainfrom
LEMS-3950/poc-logarithm-interactive-graph

Conversation

@ivyolamit
Copy link
Contributor

Summary:

POC Add logarithm answer type in Interactive Graph

  • Adds logarithm as a new answer type in the Interactive Graph widget, following the same architecture as sinusoid/tangent
  • Implements the full rendering, interaction, and scoring pipeline across 24 files
  • The logarithm graph uses the model f(x) = a * ln(b * x + c) with two movable curve points and a fully draggable vertical asymptote
  • Coefficients are computed using the inverse exponential approach (same as the legacy Grapher widget)

Key features

  • Draggable asymptote line — the entire vertical line is interactive (not just a point), using the same useDraggable + SVGLine pattern as MovableLine, with a pill-shaped drag handle for visual affordance
  • Asymptote snap-through — the asymptote can cross to the other side of the curve points, causing the curve to flip direction (matching Grapher behavior). Snap direction is based on mouse position to prevent flicker
  • Domain restriction — a single Plot.OfX with a restricted domain prop (no segment-splitting workaround needed, unlike tangent)
  • Scoring — direct approximateDeepEqual comparison of [a, b, c] coefficients (no canonical normalization needed)
  • Constraints — points can't land on the asymptote, must have different y-values, and must stay on the same side; asymptote can't land on a point

Files changed

Area Files
Data schema perseus-core/data-schema.ts, parser, generator
Component graphs/logarithm.tsx (new — 430 lines)
Reducer actions, reducer (doMovePoint + doMoveCenter), init, state
Scoring score-interactive-graph.ts with getLogarithmCoeffs()
Editor graph type selector, editor, start coords
Rendering mafs-graph.tsx, interactive-graph.tsx, state-to-graph conversion
A11y strings.ts (5 new SR strings), AI utils
Tests/Stories test data, question builder, Storybook story

Issue: LEMS-3950

Co-Authored by Claude (Opus)

Test plan:

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

github-actions bot commented Mar 10, 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-13 00:01:01.692262121 +0000
+++ /home/runner/work/_temp/branch-compare/pr/schema.d.ts	2026-03-13 00:00:51.652365607 +0000
@@ -879,7 +879,8 @@
     | PerseusGraphTypeQuadratic
     | PerseusGraphTypeRay
     | PerseusGraphTypeSegment
-    | PerseusGraphTypeSinusoid;
+    | PerseusGraphTypeSinusoid
+    | PerseusGraphTypeLogarithm;
   export type PerseusGraphTypeAngle = {
     type: "angle";
     showAngles?: boolean;
@@ -945,6 +946,13 @@
     coords?: Coord[] | null;
     startCoords?: Coord[];
   };
+  export type PerseusGraphTypeLogarithm = {
+    type: "logarithm";
+    coords?: Coord[] | null;
+    asymptote?: [Coord, Coord] | null;
+    startCoords?: Coord[];
+    startAsymptote?: [Coord, Coord];
+  };
   export type PerseusGraphTypeRay = {
     type: "ray";
     coords?: CollinearTuple | null;
@@ -993,6 +1001,11 @@
     type: "sinusoid";
     coords: CollinearTuple;
   };
+  type LogarithmGraphCorrect = {
+    type: "logarithm";
+    coords: CollinearTuple;
+    asymptote: [Coord, Coord];
+  };
   type RayGraphCorrect = {
     type: "ray";
     coords: CollinearTuple;
@@ -1008,7 +1021,8 @@
     | QuadraticGraphCorrect
     | RayGraphCorrect
     | SegmentGraphCorrect
-    | SinusoidGraphCorrect;
+    | SinusoidGraphCorrect
+    | LogarithmGraphCorrect;
   export type PerseusLabelImageWidgetOptions = {
     choices: string[];
     imageUrl: string;

@github-actions
Copy link
Contributor

github-actions bot commented Mar 10, 2026

Size Change: +4.65 kB (+0.96%)

Total Size: 491 kB

Filename Size Change
packages/perseus-core/dist/es/index.item-splitting.js 11.9 kB +34 B (+0.29%)
packages/perseus-core/dist/es/index.js 24.9 kB +34 B (+0.14%)
packages/perseus-editor/dist/es/index.js 101 kB +663 B (+0.66%)
packages/perseus-score/dist/es/index.js 9.55 kB +296 B (+3.2%)
packages/perseus/dist/es/index.js 191 kB +3.37 kB (+1.8%)
packages/perseus/dist/es/strings.js 7.73 kB +257 B (+3.44%)
ℹ️ View Unchanged
Filename Size
packages/kas/dist/es/index.js 20.8 kB
packages/keypad-context/dist/es/index.js 1 kB
packages/kmath/dist/es/index.js 5.96 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
Contributor

github-actions bot commented Mar 10, 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-13 00:01:26.387272698 +0000
+++ /home/runner/work/_temp/branch-compare/pr/index.item-splitting.js	2026-03-13 00:01:02.458159098 +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 parsePerseusGraphTypeLogarithm=object({type:constant("logarithm"),coords:optional(nullable(array(pairOfNumbers))),asymptote:optional(nullable(pair(pairOfNumbers,pairOfNumbers))),startCoords:optional(array(pairOfNumbers)),startAsymptote:optional(pair(pairOfNumbers,pairOfNumbers))});const parsePerseusGraphType=discriminatedUnionOn("type").withBranch("angle",parsePerseusGraphTypeAngle).withBranch("circle",parsePerseusGraphTypeCircle).withBranch("linear",parsePerseusGraphTypeLinear).withBranch("linear-system",parsePerseusGraphTypeLinearSystem).withBranch("logarithm",parsePerseusGraphTypeLogarithm).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:defaulted(array(string),()=>[]),label:string,x:number,y:number})),hideChoicesFromInstructions:boolean,multipleAnswers:boolean,static:defaulted(boolean,()=>false)}));
 

@github-actions
Copy link
Contributor

github-actions bot commented Mar 10, 2026

npm Snapshot: Published

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

Example:

pnpm add @khanacademy/perseus@PR3322

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

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

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

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

@ivyolamit ivyolamit force-pushed the LEMS-3950/poc-logarithm-interactive-graph branch 2 times, most recently from 96dafe6 to 915a9d7 Compare March 10, 2026 23:48
const FOCUS_RING_PAD = 2;
const FOCUS_RING_STROKE = 2;

function AsymptoteDragHandle(props: {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

add this in packages/perseus/src/widgets/interactive-graphs/graphs/components/

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 is very long, this should only contain the LogarithmGraph move other logic in its own component or util file. Maybe have packages/perseus/src/widgets/interactive-graphs/graphs/components/logarithm/ folder to contain all files and logic related to logarithm.

<OptionItem value="linear" label="Linear function" />
<OptionItem value="quadratic" label="Quadratic function" />
<OptionItem value="sinusoid" label="Sinusoid function" />
<OptionItem value="logarithm" label="Logarithm function" />
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hide this behind a feature flag

};
}

if (state.type === "logarithm" && initialGraph.type === "logarithm") {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Base on new-graph-type.md guide

Make sure this is added before the final throw at the bottom of the function.


static getLogarithmEquationString(props: Props): string {
const coords =
// @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

fix this in actual implementation

// @ts-expect-error - TS2339 - Property 'coords' does not exist on type 'PerseusGraphType'.
props.userInput.coords ||
InteractiveGraph.defaultLogarithmCoords(props);
// @ts-expect-error - TS2339 - Property 'asymptote' does not exist on type 'PerseusGraphType'.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

fix this in actual implementation

[0.55, 0.5],
[0.75, 0.75],
];
// @ts-expect-error - TS2345 - Argument of type 'number[][]' is not assignable to parameter of type 'readonly Coord[]'.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

fix this in actual implementation

};
}

// Compute logarithm coefficients from two points and an asymptote.
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 is already getting long, move this in a different file for code maintainability.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

type: props.userInput.type,
startCoords: props.userInput.startCoords,
};
case "logarithm":
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hide this behind a feature flag

…dd logarithm graph in Interactive Graph widget
1. Keyboard constraint now enforces same-side-of-asymptote — Added check (coord[X] > asymptoteX) !== otherPointSide to isValidPosition(). Points can no longer arrow-key past the asymptote.
2. Keyboard constraint no longer risks infinite loop — Replaced the two separate if-then-skip checks with a unified isValidPosition() validator inside a bounded loop (max 3 attempts). If no valid position is found, the point stays in place instead of returning an invalid coordinate.
3. Stale comment fixed — "Draggable dashed vertical asymptote line" → "Draggable vertical asymptote line"

Accessibility Fixed
1. Asymptote aria-label is now localized — Added srLogarithmAsymptote string to strings.ts (both translatable definition and mock). The label now reads: "Vertical asymptote at x equals {x}. Use left and right arrow keys to move." — formatted with srFormatNumber for locale support.
2. Asymptote position changes are now announced — Added aria-live="polite" to the asymptote <g> element. When the aria-label updates on move, screen readers will announce the new position.
3. role="button" kept as-is — After checking the codebase, all draggable elements (movable lines, points, circles) consistently use role="button". Changing to role="slider" would break the established pattern.
…the keyboard navigation, the drag handle cannot move cross the curve
…ng the curve to the opposite side of the asymptote
@ivyolamit ivyolamit force-pushed the LEMS-3950/poc-logarithm-interactive-graph branch from 2a771f6 to 1d208b0 Compare March 13, 2026 00:00
// would land between or on the curve points, snap past all points in the
// movement direction. This mirrors the reducer's snap-through logic but
// uses explicit direction (keyboard always moves one step at a time).
const constrainAsymptoteKeyboard = (
Copy link
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 a util file

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.

1 participant