Skip to content

Commit 96dafe6

Browse files
committed
[LEMS-3950/poc-logarithm-interactive-graph] [Interactive Graph] POC Add logarithm graph in Interactive Graph widget
1 parent 5bf5ca5 commit 96dafe6

23 files changed

Lines changed: 919 additions & 5 deletions

File tree

packages/perseus-core/src/data-schema.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -913,7 +913,8 @@ export type PerseusGraphType =
913913
| PerseusGraphTypeQuadratic
914914
| PerseusGraphTypeRay
915915
| PerseusGraphTypeSegment
916-
| PerseusGraphTypeSinusoid;
916+
| PerseusGraphTypeSinusoid
917+
| PerseusGraphTypeLogarithm;
917918

918919
export type PerseusGraphTypeAngle = {
919920
type: "angle";
@@ -1018,6 +1019,18 @@ export type PerseusGraphTypeSinusoid = {
10181019
startCoords?: Coord[];
10191020
};
10201021

1022+
export type PerseusGraphTypeLogarithm = {
1023+
type: "logarithm";
1024+
// Two points along the logarithmic curve.
1025+
coords?: Coord[] | null;
1026+
// Two points defining the vertical asymptote line.
1027+
asymptote?: [Coord, Coord] | null;
1028+
// The initial coordinates the graph renders with.
1029+
startCoords?: Coord[];
1030+
// The initial asymptote position the graph renders with.
1031+
startAsymptote?: [Coord, Coord];
1032+
};
1033+
10211034
export type PerseusGraphTypeRay = {
10221035
type: "ray";
10231036
// Expects a list of 2 Coords
@@ -1079,6 +1092,12 @@ type SinusoidGraphCorrect = {
10791092
coords: CollinearTuple;
10801093
};
10811094

1095+
type LogarithmGraphCorrect = {
1096+
type: "logarithm";
1097+
coords: CollinearTuple;
1098+
asymptote: [Coord, Coord];
1099+
};
1100+
10821101
type RayGraphCorrect = {
10831102
type: "ray";
10841103
coords: CollinearTuple;
@@ -1095,7 +1114,8 @@ export type PerseusGraphCorrectType =
10951114
| QuadraticGraphCorrect
10961115
| RayGraphCorrect
10971116
| SegmentGraphCorrect
1098-
| SinusoidGraphCorrect;
1117+
| SinusoidGraphCorrect
1118+
| LogarithmGraphCorrect;
10991119

11001120
export type PerseusLabelImageWidgetOptions = {
11011121
// Translatable Text; Tex representation of choices

packages/perseus-core/src/parse-perseus-json/perseus-parsers/interactive-graph-widget.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,20 @@ const parsePerseusGraphTypeSinusoid = object({
113113
startCoords: optional(array(pairOfNumbers)),
114114
});
115115

116+
const parsePerseusGraphTypeLogarithm = object({
117+
type: constant("logarithm"),
118+
coords: optional(nullable(array(pairOfNumbers))),
119+
asymptote: optional(nullable(pair(pairOfNumbers, pairOfNumbers))),
120+
startCoords: optional(array(pairOfNumbers)),
121+
startAsymptote: optional(pair(pairOfNumbers, pairOfNumbers)),
122+
});
123+
116124
export const parsePerseusGraphType = discriminatedUnionOn("type")
117125
.withBranch("angle", parsePerseusGraphTypeAngle)
118126
.withBranch("circle", parsePerseusGraphTypeCircle)
119127
.withBranch("linear", parsePerseusGraphTypeLinear)
120128
.withBranch("linear-system", parsePerseusGraphTypeLinearSystem)
129+
.withBranch("logarithm", parsePerseusGraphTypeLogarithm)
121130
.withBranch("none", parsePerseusGraphTypeNone)
122131
.withBranch("point", parsePerseusGraphTypePoint)
123132
.withBranch("polygon", parsePerseusGraphTypePolygon)

packages/perseus-core/src/utils/generators/interactive-graph-widget-generator.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
PerseusGraphTypeQuadratic,
2121
PerseusGraphTypeRay,
2222
PerseusGraphTypeSegment,
23+
PerseusGraphTypeLogarithm,
2324
PerseusGraphTypeSinusoid,
2425
PerseusInteractiveGraphWidgetOptions,
2526
} from "../../data-schema";
@@ -145,6 +146,15 @@ export function generateIGSinusoidGraph(
145146
};
146147
}
147148

149+
export function generateIGLogarithmGraph(
150+
options?: Partial<Omit<PerseusGraphTypeLogarithm, "type">>,
151+
): PerseusGraphTypeLogarithm {
152+
return {
153+
type: "logarithm",
154+
...options,
155+
};
156+
}
157+
148158
export function generateIGLockedPoint(
149159
options?: Partial<Omit<LockedPointType, "type">>,
150160
): LockedPointType {

packages/perseus-editor/src/widgets/interactive-graph-editor/components/graph-type-selector.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const GraphTypeSelector = (props: GraphTypeSelectorProps) => {
1919
<OptionItem value="linear" label="Linear function" />
2020
<OptionItem value="quadratic" label="Quadratic function" />
2121
<OptionItem value="sinusoid" label="Sinusoid function" />
22+
<OptionItem value="logarithm" label="Logarithm function" />
2223
<OptionItem value="circle" label="Circle" />
2324
<OptionItem value="point" label="Point(s)" />
2425
<OptionItem value="linear-system" label="Linear System" />

packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,9 @@ function mergeGraphs(
521521
case "sinusoid":
522522
invariant(b.type === "sinusoid");
523523
return {...a, ...b};
524+
case "logarithm":
525+
invariant(b.type === "logarithm");
526+
return {...a, ...b};
524527
default:
525528
throw new UnreachableCaseError(a);
526529
}

packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ type GraphTypesThatHaveStartCoords =
1010
| {type: "quadratic"}
1111
| {type: "ray"}
1212
| {type: "segment"}
13-
| {type: "sinusoid"};
13+
| {type: "sinusoid"}
14+
| {type: "logarithm"};
1415

1516
export type StartCoords = Extract<
1617
PerseusGraphType,

packages/perseus-editor/src/widgets/interactive-graph-editor/start-coords/util.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getPolygonCoords,
99
getQuadraticCoords,
1010
getSegmentCoords,
11+
getLogarithmCoords,
1112
getSinusoidCoords,
1213
} from "@khanacademy/perseus";
1314
import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core";
@@ -64,6 +65,12 @@ export function getDefaultGraphStartCoords(
6465
range,
6566
step,
6667
);
68+
case "logarithm":
69+
return getLogarithmCoords(
70+
{...graph, startCoords: undefined, startAsymptote: undefined},
71+
range,
72+
step,
73+
).coords;
6774
case "quadratic":
6875
return getQuadraticCoords(
6976
{...graph, startCoords: undefined},
@@ -192,6 +199,7 @@ export const shouldShowStartCoordsUI = (
192199
case "ray":
193200
case "segment":
194201
case "sinusoid":
202+
case "logarithm":
195203
return true;
196204
default:
197205
throw new UnreachableCaseError(graph);

packages/perseus-score/src/widgets/interactive-graph/score-interactive-graph.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,38 @@ function scoreInteractiveGraph(
144144
message: null,
145145
};
146146
}
147+
} else if (
148+
userInput.type === "logarithm" &&
149+
rubric.correct.type === "logarithm" &&
150+
userInput.coords != null &&
151+
userInput.asymptote != null
152+
) {
153+
// For logarithm, we compare the coefficients directly.
154+
// The logarithm doesn't have periodic equivalences, so no
155+
// canonical normalization is needed.
156+
const guessCoeffs = getLogarithmCoeffs(
157+
userInput.coords,
158+
userInput.asymptote,
159+
);
160+
const correctCoeffs = getLogarithmCoeffs(
161+
rubric.correct.coords,
162+
rubric.correct.asymptote,
163+
);
164+
if (
165+
guessCoeffs &&
166+
correctCoeffs &&
167+
approximateDeepEqual(
168+
[guessCoeffs.a, guessCoeffs.b, guessCoeffs.c],
169+
[correctCoeffs.a, correctCoeffs.b, correctCoeffs.c],
170+
)
171+
) {
172+
return {
173+
type: "points",
174+
earned: 1,
175+
total: 1,
176+
message: null,
177+
};
178+
}
147179
} else if (
148180
userInput.type === "circle" &&
149181
rubric.correct.type === "circle"
@@ -327,4 +359,53 @@ function scoreInteractiveGraph(
327359
};
328360
}
329361

362+
// Compute logarithm coefficients from two points and an asymptote.
363+
// Uses the inverse exponential approach.
364+
// Formula: y = a * ln(b * x + c)
365+
function getLogarithmCoeffs(
366+
coords: ReadonlyArray<Coord>,
367+
asymptote: ReadonlyArray<Coord>,
368+
): {a: number; b: number; c: number} | undefined {
369+
const p1 = coords[0];
370+
const p2 = coords[1];
371+
const asymptoteX = asymptote[0][0];
372+
373+
if (p1[1] === p2[1]) {
374+
return;
375+
}
376+
if (p1[0] === asymptoteX || p2[0] === asymptoteX) {
377+
return;
378+
}
379+
380+
// Flip (x,y) -> (y,x) to convert to exponential
381+
const flip = (coord: Coord): Coord => [coord[1], coord[0]];
382+
const flippedCoords = [flip(p1), flip(p2)];
383+
const cExp = asymptoteX;
384+
385+
const denom = flippedCoords[0][0] - flippedCoords[1][0];
386+
if (denom === 0) {
387+
return;
388+
}
389+
390+
const bExp =
391+
Math.log((flippedCoords[0][1] - cExp) / (flippedCoords[1][1] - cExp)) /
392+
denom;
393+
const aExp =
394+
(flippedCoords[0][1] - cExp) / Math.exp(bExp * flippedCoords[0][0]);
395+
396+
if (!isFinite(aExp) || !isFinite(bExp) || aExp === 0) {
397+
return;
398+
}
399+
400+
const c = -cExp / aExp;
401+
const b = 1 / aExp;
402+
const a = 1 / bExp;
403+
404+
if (!isFinite(a) || !isFinite(b) || !isFinite(c)) {
405+
return;
406+
}
407+
408+
return {a, b, c};
409+
}
410+
330411
export default scoreInteractiveGraph;

packages/perseus/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export {
115115
getPointCoords,
116116
getPolygonCoords,
117117
getSegmentCoords,
118+
getLogarithmCoords,
118119
getSinusoidCoords,
119120
getQuadraticCoords,
120121
getAngleCoords,

packages/perseus/src/strings.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,35 @@ export type PerseusStrings = {
492492
point2X: string;
493493
point2Y: string;
494494
}) => string;
495+
srLogarithmGraph: string;
496+
srLogarithmPoint1: ({x, y}: {x: string; y: string}) => string;
497+
srLogarithmPoint2: ({x, y}: {x: string; y: string}) => string;
498+
srLogarithmDescription: ({
499+
point1X,
500+
point1Y,
501+
point2X,
502+
point2Y,
503+
asymptoteX,
504+
}: {
505+
point1X: string;
506+
point1Y: string;
507+
point2X: string;
508+
point2Y: string;
509+
asymptoteX: string;
510+
}) => string;
511+
srLogarithmInteractiveElements: ({
512+
point1X,
513+
point1Y,
514+
point2X,
515+
point2Y,
516+
asymptoteX,
517+
}: {
518+
point1X: string;
519+
point1Y: string;
520+
point2X: string;
521+
point2Y: string;
522+
asymptoteX: string;
523+
}) => string;
495524
imageExploreButton: string;
496525
imageAlternativeTitle: string;
497526
imageDescriptionLabel: string;
@@ -1095,6 +1124,33 @@ export const strings = {
10951124
message:
10961125
"Sinusoid graph with midline intersection point at %(point1X)s comma %(point1Y)s and extremum point at %(point2X)s comma %(point2Y)s.",
10971126
},
1127+
srLogarithmGraph: {
1128+
context:
1129+
"Aria label for the container containing a Logarithm function in the interactive graph widget.",
1130+
message: "A logarithm function on a coordinate plane.",
1131+
},
1132+
srLogarithmPoint1: {
1133+
context:
1134+
"Aria label for the first Point on the Logarithm function in the interactive graph widget.",
1135+
message: "Point 1 at %(x)s comma %(y)s.",
1136+
},
1137+
srLogarithmPoint2: {
1138+
context:
1139+
"Aria label for the second Point on the Logarithm function in the interactive graph widget.",
1140+
message: "Point 2 at %(x)s comma %(y)s.",
1141+
},
1142+
srLogarithmDescription: {
1143+
context:
1144+
"Screen reader description of the Logarithm function in the interactive graph widget.",
1145+
message:
1146+
"The graph shows a logarithmic curve passing through point %(point1X)s comma %(point1Y)s and point %(point2X)s comma %(point2Y)s with a vertical asymptote at x equals %(asymptoteX)s.",
1147+
},
1148+
srLogarithmInteractiveElements: {
1149+
context:
1150+
"Screen reader description of all the elements available to interact with within the Logarithm function in the interactive graph widget.",
1151+
message:
1152+
"Logarithm graph with point 1 at %(point1X)s comma %(point1Y)s, point 2 at %(point2X)s comma %(point2Y)s, and vertical asymptote at x equals %(asymptoteX)s.",
1153+
},
10981154
imageExploreButton: "Explore image",
10991155
imageAlternativeTitle: "Explore image and description",
11001156
imageDescriptionLabel: "Description",
@@ -1430,6 +1486,25 @@ export const mockStrings: PerseusStrings = {
14301486
`The graph shows a wave with a minimum value of ${minValue} and a maximum value of ${maxValue}. The wave completes a full cycle from ${cycleStart} to ${cycleEnd}.`,
14311487
srSinusoidInteractiveElements: ({point1X, point1Y, point2X, point2Y}) =>
14321488
`Sinusoid graph with midline intersection point at ${point1X} comma ${point1Y} and extremum point at ${point2X} comma ${point2Y}.`,
1489+
srLogarithmGraph: "A logarithm function on a coordinate plane.",
1490+
srLogarithmPoint1: ({x, y}) => `Point 1 at ${x} comma ${y}.`,
1491+
srLogarithmPoint2: ({x, y}) => `Point 2 at ${x} comma ${y}.`,
1492+
srLogarithmDescription: ({
1493+
point1X,
1494+
point1Y,
1495+
point2X,
1496+
point2Y,
1497+
asymptoteX,
1498+
}) =>
1499+
`The graph shows a logarithmic curve passing through point ${point1X} comma ${point1Y} and point ${point2X} comma ${point2Y} with a vertical asymptote at x equals ${asymptoteX}.`,
1500+
srLogarithmInteractiveElements: ({
1501+
point1X,
1502+
point1Y,
1503+
point2X,
1504+
point2Y,
1505+
asymptoteX,
1506+
}) =>
1507+
`Logarithm graph with point 1 at ${point1X} comma ${point1Y}, point 2 at ${point2X} comma ${point2Y}, and vertical asymptote at x equals ${asymptoteX}.`,
14331508
imageExploreButton: "Explore image",
14341509
imageAlternativeTitle: "Explore image and description",
14351510
imageDescriptionLabel: "Description",

0 commit comments

Comments
 (0)