From 4b56e10dedc9b1ddc82bf7e7406ffdaecdef7462 Mon Sep 17 00:00:00 2001 From: Mark Fitzgerald <13896410+mark-fitzgerald@users.noreply.github.com> Date: Tue, 2 Jul 2024 12:00:39 -0700 Subject: [PATCH] View Locked Function in the Interactive Graph (#1383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: Existing locked figures include lines that are straight. The need exists to also have lines that follow a function/equation. Plotting arbitrary functions as a locked figure will provide content creators with a powerful drawing tool for interactive graphs. Issue: LEMS-1944 ## Test plan: 1. Launch Storybook (yarn start) 1. Navigate to Perseus => Widgets => Interactive Graph => [Locked Functions](http://localhost:6006/?path=/docs/perseus-widgets-interactive-graph-locked-functions--docs) 1. Note that there are multiple stories available to demonstrate various functions and options ## Examples: **Quadratic** ![Quadratic](https://github.com/Khan/perseus/assets/13896410/70abc39b-c1ec-482a-bb63-793626fe28a8) **Sine** ![Sine](https://github.com/Khan/perseus/assets/13896410/c4617d53-5fda-47f4-8792-d3fb4a73942a) **Logarithmic** ![Logarithmic](https://github.com/Khan/perseus/assets/13896410/af903a44-16ad-4d9c-bbf8-5ba95ccd4925) Author: mark-fitzgerald Reviewers: nishasy, mark-fitzgerald, #perseus Required Reviewers: Approved By: nishasy Checks: ✅ codecov/project, ✅ codecov/patch, ✅ Upload Coverage (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Jest Coverage (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ gerald Pull Request URL: https://github.com/Khan/perseus/pull/1383 --- .changeset/brave-steaks-collect.md | 6 + .../src/components/__tests__/util.test.ts | 11 ++ .../locked-figures-section.tsx | 6 + .../perseus-editor/src/components/util.ts | 10 ++ packages/perseus/src/index.ts | 1 + packages/perseus/src/perseus-types.ts | 24 ++- .../__stories__/locked-functions.stories.tsx | 122 ++++++++++++++++ .../interactive-graph.testdata.ts | 10 ++ .../__tests__/interactive-graph.test.ts | 138 ++++++++++++++++-- .../interactive-graphs/graph-locked-layer.tsx | 8 + ...interactive-graph-question-builder.test.ts | 40 +++++ .../interactive-graph-question-builder.ts | 26 +++- .../locked-figures/locked-function.tsx | 30 ++++ 13 files changed, 418 insertions(+), 14 deletions(-) create mode 100644 .changeset/brave-steaks-collect.md create mode 100644 packages/perseus/src/widgets/__stories__/locked-functions.stories.tsx create mode 100644 packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-function.tsx diff --git a/.changeset/brave-steaks-collect.md b/.changeset/brave-steaks-collect.md new file mode 100644 index 0000000000..63eecc50a8 --- /dev/null +++ b/.changeset/brave-steaks-collect.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": minor +"@khanacademy/perseus-editor": patch +--- + +View Locked Functions in the Interactive Graph diff --git a/packages/perseus-editor/src/components/__tests__/util.test.ts b/packages/perseus-editor/src/components/__tests__/util.test.ts index fe10a6551f..53a959113a 100644 --- a/packages/perseus-editor/src/components/__tests__/util.test.ts +++ b/packages/perseus-editor/src/components/__tests__/util.test.ts @@ -77,6 +77,17 @@ describe("getDefaultFigureForType", () => { strokeStyle: "solid", }); }); + + test("should return a 'function' with default values", () => { + const figure = getDefaultFigureForType("function"); + expect(figure).toEqual({ + type: "function", + color: "grayH", + strokeStyle: "solid", + equation: "x^2", + directionalAxis: "x", + }); + }); }); describe("degreeToRadian", () => { diff --git a/packages/perseus-editor/src/components/graph-locked-figures/locked-figures-section.tsx b/packages/perseus-editor/src/components/graph-locked-figures/locked-figures-section.tsx index 4963872069..5d45893c23 100644 --- a/packages/perseus-editor/src/components/graph-locked-figures/locked-figures-section.tsx +++ b/packages/perseus-editor/src/components/graph-locked-figures/locked-figures-section.tsx @@ -151,6 +151,12 @@ const LockedFiguresSection = (props: Props) => { return ( {figures?.map((figure, index) => { + if (figure.type === "function") { + // TODO(LEMS-1947): Add locked function settings. + // Remove this block once function locked figure settings are + // implemented. + return; + } return ( ; + +export const DefaultSettings = (args: StoryArgs): React.ReactElement => ( + +); + +export const StyledSettings = (args: StoryArgs): React.ReactElement => ( + +); + +export const FunctionOfY = (args: StoryArgs): React.ReactElement => ( + +); + +export const DomainRestrictedMin = (args: StoryArgs): React.ReactElement => ( + +); + +export const DomainRestrictedMax = (args: StoryArgs): React.ReactElement => ( + +); + +export const DomainRestrictedBoth = (args: StoryArgs): React.ReactElement => ( + +); + +export const Quadratic = (args: StoryArgs): React.ReactElement => ( + +); + +export const QubicPolynomial = (args: StoryArgs): React.ReactElement => ( + +); + +export const Tangent = (args: StoryArgs): React.ReactElement => ( + +); + +export const ArcTangent = (args: StoryArgs): React.ReactElement => ( + +); + +export const Logarithmic = (args: StoryArgs): React.ReactElement => ( + +); + +export const Exponent = (args: StoryArgs): React.ReactElement => ( + +); + +export const AbsoluteValue = (args: StoryArgs): React.ReactElement => ( + +); diff --git a/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts b/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts index 266563f2ce..583ca5cd2e 100644 --- a/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts +++ b/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts @@ -2,6 +2,7 @@ import {interactiveGraphQuestionBuilder} from "../interactive-graphs/interactive import type {Coord} from "../../interactive2/types"; import type {PerseusRenderer} from "../../perseus-types"; +import type {LockedFunctionOptions} from "../interactive-graphs/interactive-graph-question-builder"; // Data for the interactive graph widget @@ -2225,6 +2226,15 @@ export const segmentWithLockedPolygons: PerseusRenderer = ) .build(); +export const segmentWithLockedFunction = ( + equation: string = "x^2", + options?: LockedFunctionOptions, +): PerseusRenderer => { + return interactiveGraphQuestionBuilder() + .addLockedFunction(equation, options) + .build(); +}; + export const segmentWithLockedFigures: PerseusRenderer = interactiveGraphQuestionBuilder() .addLockedPointAt(-7, -7) diff --git a/packages/perseus/src/widgets/__tests__/interactive-graph.test.ts b/packages/perseus/src/widgets/__tests__/interactive-graph.test.ts index 7e4e1199dc..1550b50ad4 100644 --- a/packages/perseus/src/widgets/__tests__/interactive-graph.test.ts +++ b/packages/perseus/src/widgets/__tests__/interactive-graph.test.ts @@ -1,7 +1,9 @@ import {describe, beforeEach, it} from "@jest/globals"; +import * as KAS from "@khanacademy/kas"; import {color as wbColor} from "@khanacademy/wonder-blocks-tokens"; import {waitFor} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; +import {Plot} from "mafs"; import {clone} from "../../../../../testing/object-utils"; import {testDependencies} from "../../../../../testing/test-dependencies"; @@ -28,6 +30,7 @@ import { segmentQuestion, segmentQuestionDefaultCorrect, segmentWithLockedEllipses, + segmentWithLockedFunction, segmentWithLockedLineQuestion, segmentWithLockedPointsQuestion, segmentWithLockedPointsWithColorQuestion, @@ -257,7 +260,7 @@ describe("a mafs graph", () => { expect(points).toHaveLength(2); }); - test("should render locked points with styles", async () => { + it("should render locked points with styles", async () => { // Arrange const {container} = renderQuestion( segmentWithLockedPointsQuestion, @@ -400,7 +403,7 @@ describe("locked layer", () => { expect(points).toHaveLength(2); }); - test("should render locked points with styles when color is not specified", async () => { + it("should render locked points with styles when color is not specified", async () => { // Arrange const {container} = renderQuestion( segmentWithLockedPointsQuestion, @@ -424,7 +427,7 @@ describe("locked layer", () => { }); }); - test("should render locked points with styles when color is specified", async () => { + it("should render locked points with styles when color is specified", async () => { // Arrange const {container} = renderQuestion( segmentWithLockedPointsWithColorQuestion, @@ -454,7 +457,7 @@ describe("locked layer", () => { }); }); - test("should render locked lines", () => { + it("should render locked lines", () => { // Arrange const {container} = renderQuestion(segmentWithLockedLineQuestion, { flags: { @@ -475,7 +478,7 @@ describe("locked layer", () => { expect(rays).toHaveLength(1); }); - test("should render locked lines with styles", () => { + it("should render locked lines with styles", () => { // Arrange const {container} = renderQuestion(segmentWithLockedLineQuestion, { flags: { @@ -498,7 +501,7 @@ describe("locked layer", () => { expect(ray).toHaveStyle({stroke: lockedFigureColors.pink}); }); - test("should render locked lines with shown points", async () => { + it("should render locked lines with shown points", async () => { // Arrange const {container} = renderQuestion(segmentWithLockedLineQuestion, { flags: { @@ -539,7 +542,7 @@ describe("locked layer", () => { }); }); - test("should render locked vectors", async () => { + it("should render locked vectors", async () => { // Arrange const {container} = renderQuestion(segmentWithLockedVectors, { flags: { @@ -587,7 +590,7 @@ describe("locked layer", () => { ); }); - test("should render locked ellipses", async () => { + it("should render locked ellipses", async () => { // Arrange const {container} = renderQuestion(segmentWithLockedEllipses, { flags: { @@ -617,7 +620,7 @@ describe("locked layer", () => { }); }); - test("should render locked polygons with style", async () => { + it("should render locked polygons with style", async () => { // Arrange const {container} = renderQuestion(segmentWithLockedPolygons, { flags: { @@ -647,7 +650,7 @@ describe("locked layer", () => { }); }); - test("should render vertices of locked polygons with showVertices", async () => { + it("should render vertices of locked polygons with showVertices", async () => { // Arrange const {container} = renderQuestion(segmentWithLockedPolygons, { flags: { @@ -681,4 +684,119 @@ describe("locked layer", () => { fill: lockedFigureColors["green"], }); }); + + it("should render locked function with style", () => { + // Arrange + const {container} = renderQuestion( + segmentWithLockedFunction("x^2", { + color: "green", + strokeStyle: "dashed", + }), + { + flags: { + mafs: { + segment: true, + }, + }, + }, + ); + + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const functionPlots = container.querySelectorAll( + ".locked-function path", + ); + + // Assert + expect(functionPlots).toHaveLength(1); + expect(functionPlots[0]).toHaveStyle({ + "stroke-dasharray": "var(--mafs-line-stroke-dash-style)", + stroke: lockedFigureColors["green"], + }); + }); + + it("parses equation only when needed for a locked function", () => { + const PARSED = "equation needed parsing"; + const PREPARSED = "equation was pre-parsed"; + // Arrange - equation needs parsing + const KasParseMock = jest + .spyOn(KAS, "parse") + .mockReturnValue({expr: {eval: () => PARSED}}); + const PlotMock = jest.spyOn(Plot, "OfX").mockImplementation(); + renderQuestion(segmentWithLockedFunction("x^2"), { + flags: { + mafs: { + segment: true, + }, + }, + }); + let plotFn = PlotMock.mock.calls[0][0]["y"]; + + // Assert + expect(KasParseMock).toHaveBeenCalledTimes(1); + expect(plotFn(0)).toEqual(PARSED); + + // Arrange - equation does NOT need parsing + KasParseMock.mockReset(); + PlotMock.mockReset(); + renderQuestion( + segmentWithLockedFunction("x^2", { + equationParsed: {eval: () => PREPARSED}, + }), + { + flags: { + mafs: { + segment: true, + }, + }, + }, + ); + plotFn = PlotMock.mock.calls[0][0]["y"]; + + // Assert + expect(KasParseMock).toHaveBeenCalledTimes(0); + expect(plotFn(0)).toEqual(PREPARSED); + }); + + it("plots the supplied equation on the axis specified", () => { + // Arrange + const apiOptions = { + flags: { + mafs: { + segment: true, + }, + }, + }; + const PlotOfXMock = jest.spyOn(Plot, "OfX").mockReturnValue(null); + const PlotOfYMock = jest.spyOn(Plot, "OfY").mockReturnValue(null); + const equationFnMock = jest.fn(); + + // Act - Render f(x) + renderQuestion(segmentWithLockedFunction("x^2"), apiOptions); + + // Assert + expect(PlotOfXMock).toHaveBeenCalledTimes(1); + expect(PlotOfYMock).toHaveBeenCalledTimes(0); + + // Arrange - reset mocks + PlotOfXMock.mockReset(); + + // Act - Render f(y) + renderQuestion( + segmentWithLockedFunction("x^2", { + directionalAxis: "y", + equationParsed: { + eval: equationFnMock, + }, + }), + apiOptions, + ); + + // Assert + expect(PlotOfXMock).toHaveBeenCalledTimes(0); + expect(PlotOfYMock).toHaveBeenCalledTimes(1); + PlotOfYMock.mock.calls[0][0]["x"](1.21); // Execute the plot function + expect(equationFnMock).toHaveBeenCalledTimes(1); + expect(equationFnMock).toHaveBeenCalledWith({y: 1.21}); + }); }); diff --git a/packages/perseus/src/widgets/interactive-graphs/graph-locked-layer.tsx b/packages/perseus/src/widgets/interactive-graphs/graph-locked-layer.tsx index fc0dd02120..a6959107ba 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graph-locked-layer.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graph-locked-layer.tsx @@ -2,6 +2,7 @@ import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core"; import * as React from "react"; import LockedEllipse from "./locked-figures/locked-ellipse"; +import LockedFunction from "./locked-figures/locked-function"; import LockedLine from "./locked-figures/locked-line"; import LockedPoint from "./locked-figures/locked-point"; import LockedPolygon from "./locked-figures/locked-polygon"; @@ -51,6 +52,13 @@ const GraphLockedLayer = (props: Props) => { {...figure} /> ); + case "function": + return ( + + ); default: /** * Devlopment-time future-proofing: This should diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.test.ts b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.test.ts index 6b6bff7755..7d47efea66 100644 --- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.test.ts @@ -711,4 +711,44 @@ describe("InteractiveGraphQuestionBuilder", () => { }, ]); }); + + it("adds a locked function", () => { + const question: PerseusRenderer = interactiveGraphQuestionBuilder() + .addLockedFunction("x^2") + .build(); + const graph = question.widgets["interactive-graph 1"]; + + expect(graph.options.lockedFigures).toEqual([ + { + type: "function", + equation: "x^2", + color: "grayH", + strokeStyle: "solid", + directionalAxis: "x", + }, + ]); + }); + + it("adds a locked function with options", () => { + const question: PerseusRenderer = interactiveGraphQuestionBuilder() + .addLockedFunction("x^2", { + color: "green", + strokeStyle: "dashed", + directionalAxis: "y", + domain: {min: -5, max: 5}, + }) + .build(); + const graph = question.widgets["interactive-graph 1"]; + + expect(graph.options.lockedFigures).toEqual([ + { + type: "function", + equation: "x^2", + color: "green", + strokeStyle: "dashed", + directionalAxis: "y", + domain: {min: -5, max: 5}, + }, + ]); + }); }); diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts index d4fb055f2e..5fce468431 100644 --- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph-question-builder.ts @@ -1,19 +1,25 @@ import type { - LockedFigureFillType, + CollinearTuple, LockedEllipseType, LockedFigure, LockedFigureColor, + LockedFigureFillType, + LockedFunctionType, LockedLineType, LockedPointType, + LockedPolygonType, LockedVectorType, PerseusGraphType, PerseusRenderer, - LockedPolygonType, - CollinearTuple, } from "../../perseus-types"; import type {Coord} from "@khanacademy/perseus"; import type {Interval, vec} from "mafs"; +export type LockedFunctionOptions = Omit< + Partial, + "type" | "equation" +>; + export function interactiveGraphQuestionBuilder(): InteractiveGraphQuestionBuilder { return new InteractiveGraphQuestionBuilder(); } @@ -252,6 +258,20 @@ class InteractiveGraphQuestionBuilder { return this; } + addLockedFunction(equation: string, options?: LockedFunctionOptions) { + const lockedFunction: LockedFunctionType = { + type: "function", + equation, + color: "grayH", + strokeStyle: "solid", + directionalAxis: "x", + ...options, + }; + + this.addLockedFigure(lockedFunction); + return this; + } + private createLockedPoint(x: number, y: number): LockedPointType { return {type: "point", coord: [x, y], color: "green", filled: true}; } diff --git a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-function.tsx b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-function.tsx new file mode 100644 index 0000000000..10fb49345d --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-function.tsx @@ -0,0 +1,30 @@ +import * as KAS from "@khanacademy/kas"; +import {Plot} from "mafs"; +import * as React from "react"; + +import {lockedFigureColors} from "../../../perseus-types"; + +import type {LockedFunctionType} from "../../../perseus-types"; + +const LockedFunction = (props: LockedFunctionType) => { + const {color, strokeStyle, directionalAxis, domain} = props; + const plotProps = { + color: lockedFigureColors[color], + style: strokeStyle, + domain, + }; + const equation = props.equationParsed || KAS.parse(props.equation).expr; + + return ( + + {directionalAxis === "x" && ( + equation.eval({x})} {...plotProps} /> + )} + {directionalAxis === "y" && ( + equation.eval({y})} {...plotProps} /> + )} + + ); +}; + +export default LockedFunction;