From af68a9e082f7b042aeb516ddba38274fcc66c4fc Mon Sep 17 00:00:00 2001 From: Nisha Yerunkar Date: Wed, 31 Jul 2024 14:20:47 -0700 Subject: [PATCH] [Hint Mode: Start Coords] Add start coords UI for sinusoid graphs (#1468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: Add the UI to specify start coords for Sinusoid graph type. - Add the sinusoid graph type to start-coord-settings.tsx - Create a start-coords-sinusoid.tsx file with the main UI - Add a utility for getting the sinusoid equation from coordinates (less complex than the static method on InteractiveGraph that does the same) - Add start coords UI phase 2 flag Issue: https://khanacademy.atlassian.net/browse/LEMS-2207 ## Test plan: `yarn jest` Storybook - http://localhost:6006/?path=/story/perseuseditor-widgets-interactive-graph--interactive-graph-sinusoid-with-starting-coords Author: nishasy Reviewers: nishasy, mark-fitzgerald, benchristel Required Reviewers: Approved By: mark-fitzgerald Checks: ✅ codecov/project, ✅ codecov/patch, ✅ Upload Coverage (ubuntu-latest, 20.x), ✅ gerald, ⏭️ Publish npm snapshot, ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Jest Coverage (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), 🚫 Upload Coverage, ✅ gerald, ⏭️ Publish npm snapshot, ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), 🚫 Jest Coverage (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ gerald Pull Request URL: https://github.com/Khan/perseus/pull/1468 --- .changeset/eighty-ducks-appear.md | 6 ++ .../src/__stories__/flags-for-api-options.ts | 1 + .../__tests__/start-coords-settings.test.tsx | 64 +++++++++++++++ .../src/components/__tests__/util.test.ts | 21 +++++ .../src/components/start-coords-settings.tsx | 11 +++ .../src/components/start-coords-sinusoid.tsx | 80 +++++++++++++++++++ .../perseus-editor/src/components/util.ts | 33 +++++++- .../interactive-graph-editor.test.tsx | 58 ++++++++++++++ .../src/widgets/interactive-graph-editor.tsx | 20 +++-- packages/perseus/src/index.ts | 1 + packages/perseus/src/types.ts | 5 ++ .../reducer/initialize-graph-state.ts | 2 +- 12 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 .changeset/eighty-ducks-appear.md create mode 100644 packages/perseus-editor/src/components/start-coords-sinusoid.tsx diff --git a/.changeset/eighty-ducks-appear.md b/.changeset/eighty-ducks-appear.md new file mode 100644 index 0000000000..0b14315465 --- /dev/null +++ b/.changeset/eighty-ducks-appear.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": minor +"@khanacademy/perseus-editor": minor +--- + +[Hint Mode: Start Coords] Add start coords UI for sinusoid graphs diff --git a/packages/perseus-editor/src/__stories__/flags-for-api-options.ts b/packages/perseus-editor/src/__stories__/flags-for-api-options.ts index fbed34fb26..4fcd2a858a 100644 --- a/packages/perseus-editor/src/__stories__/flags-for-api-options.ts +++ b/packages/perseus-editor/src/__stories__/flags-for-api-options.ts @@ -19,6 +19,7 @@ export const flags = { // Start coords UI flags // TODO(LEMS-2228): Remove flags once this is fully released "start-coords-ui-phase-1": true, + "start-coords-ui-phase-2": true, }, } satisfies APIOptions["flags"]; diff --git a/packages/perseus-editor/src/components/__tests__/start-coords-settings.test.tsx b/packages/perseus-editor/src/components/__tests__/start-coords-settings.test.tsx index 3cc6c53f24..8b9b4ea4ad 100644 --- a/packages/perseus-editor/src/components/__tests__/start-coords-settings.test.tsx +++ b/packages/perseus-editor/src/components/__tests__/start-coords-settings.test.tsx @@ -391,4 +391,68 @@ describe("StartCoordSettings", () => { }, ); }); + + describe("sinusoid graph", () => { + test("shows the start coordinates UI", () => { + // Arrange + + // Act + render( + {}} + />, + {wrapper: RenderStateRoot}, + ); + + // Assert + expect(screen.getByText("Start coordinates")).toBeInTheDocument(); + expect(screen.getByText("Starting equation:")).toBeInTheDocument(); + expect( + // Equation for default start coords + screen.getByText("y = 2.000sin(0.524x - 0.000) + 0.000"), + ).toBeInTheDocument(); + expect(screen.getByText("Point 1:")).toBeInTheDocument(); + expect(screen.getByText("Point 2:")).toBeInTheDocument(); + }); + + test.each` + pointIndex | coord + ${0} | ${"x"} + ${0} | ${"y"} + ${1} | ${"x"} + ${1} | ${"y"} + `( + "calls onChange when $coord coord is changed (line $pointIndex)", + async ({pointIndex, coord}) => { + // Arrange + const onChangeMock = jest.fn(); + + // Act + render( + , + ); + + // Assert + const input = screen.getAllByRole("spinbutton", { + name: `${coord}`, + })[pointIndex]; + await userEvent.clear(input); + await userEvent.type(input, "101"); + + const expectedCoords = [ + [0, 0], + [3, 2], + ]; + expectedCoords[pointIndex][coord === "x" ? 0 : 1] = 101; + + expect(onChangeMock).toHaveBeenLastCalledWith(expectedCoords); + }, + ); + }); }); diff --git a/packages/perseus-editor/src/components/__tests__/util.test.ts b/packages/perseus-editor/src/components/__tests__/util.test.ts index 399e369d85..ce4502237d 100644 --- a/packages/perseus-editor/src/components/__tests__/util.test.ts +++ b/packages/perseus-editor/src/components/__tests__/util.test.ts @@ -3,6 +3,7 @@ import { getDefaultFigureForType, radianToDegree, getDefaultGraphStartCoords, + getSinusoidEquation, } from "../util"; import type {PerseusGraphType, Range} from "@khanacademy/perseus"; @@ -255,3 +256,23 @@ describe("getDefaultGraphStartCoords", () => { expect(defaultCoords).toEqual({center: [0, 0], radius: 2}); }); }); + +describe("getSinusoidEquation", () => { + test.each` + point1 | point2 | expected + ${[0, 0]} | ${[3, 2]} | ${"y = 2.000sin(0.524x - 0.000) + 0.000"} + ${[0, 0]} | ${[1, 0]} | ${"y = 0.000sin(1.571x - 0.000) + 0.000"} + ${[0, 0]} | ${[1, 1]} | ${"y = 1.000sin(1.571x - 0.000) + 0.000"} + ${[0, 0]} | ${[1, -1]} | ${"y = -1.000sin(1.571x - 0.000) + 0.000"} + ${[0, 0]} | ${[-1, 0]} | ${"y = 0.000sin(-1.571x - 0.000) + 0.000"} + ${[-1, 0]} | ${[1, 1]} | ${"y = 1.000sin(0.785x - -0.785) + 0.000"} + ${[0, -1]} | ${[1, 1]} | ${"y = 2.000sin(1.571x - 0.000) + -1.000"} + ${[-9, -9]} | ${[9, 9]} | ${"y = 18.000sin(0.087x - -0.785) + -9.000"} + ${[3, -4]} | ${[6, 7]} | ${"y = 11.000sin(0.524x - 1.571) + -4.000"} + `("should return the correct equation", ({point1, point2, expected}) => { + // Act + const equation = getSinusoidEquation([point1, point2]); + + expect(equation).toBe(expected); + }); +}); diff --git a/packages/perseus-editor/src/components/start-coords-settings.tsx b/packages/perseus-editor/src/components/start-coords-settings.tsx index c83ff4bc67..f85ed442ca 100644 --- a/packages/perseus-editor/src/components/start-coords-settings.tsx +++ b/packages/perseus-editor/src/components/start-coords-settings.tsx @@ -4,6 +4,7 @@ import { getLineCoords, getLinearSystemCoords, getSegmentCoords, + getSinusoidCoords, } from "@khanacademy/perseus"; import Button from "@khanacademy/wonder-blocks-button"; import {View} from "@khanacademy/wonder-blocks-core"; @@ -16,6 +17,7 @@ import Heading from "./heading"; import StartCoordsCircle from "./start-coords-circle"; import StartCoordsLine from "./start-coords-line"; import StartCoordsMultiline from "./start-coords-multiline"; +import StartCoordsSinusoid from "./start-coords-sinusoid"; import {getDefaultGraphStartCoords} from "./util"; import type {PerseusGraphType, Range} from "@khanacademy/perseus"; @@ -69,6 +71,15 @@ const StartCoordsSettingsInner = (props: Props) => { onChange={onChange} /> ); + case "sinusoid": + const sinusoidCoords = getSinusoidCoords(props, range, step); + return ( + + ); + default: return null; } diff --git a/packages/perseus-editor/src/components/start-coords-sinusoid.tsx b/packages/perseus-editor/src/components/start-coords-sinusoid.tsx new file mode 100644 index 0000000000..cdef17e4b5 --- /dev/null +++ b/packages/perseus-editor/src/components/start-coords-sinusoid.tsx @@ -0,0 +1,80 @@ +import {View} from "@khanacademy/wonder-blocks-core"; +import {Strut} from "@khanacademy/wonder-blocks-layout"; +import {color, font, spacing} from "@khanacademy/wonder-blocks-tokens"; +import { + BodyMonospace, + LabelLarge, + LabelMedium, +} from "@khanacademy/wonder-blocks-typography"; +import {StyleSheet} from "aphrodite"; +import * as React from "react"; + +import CoordinatePairInput from "./coordinate-pair-input"; +import {getSinusoidEquation} from "./util"; + +import type {Coord, PerseusGraphType} from "@khanacademy/perseus"; + +type Props = { + startCoords: [Coord, Coord]; + onChange: (startCoords: PerseusGraphType["startCoords"]) => void; +}; + +const StartCoordsSinusoid = (props: Props) => { + const {startCoords, onChange} = props; + + return ( + <> + {/* Current equation */} + + Starting equation: + + {getSinusoidEquation(startCoords)} + + + + {/* Points UI */} + + Point 1: + + onChange([value, startCoords[1]])} + /> + + + Point 2: + + onChange([startCoords[0], value])} + /> + + + ); +}; + +const styles = StyleSheet.create({ + tile: { + backgroundColor: color.fadedBlue8, + marginTop: spacing.xSmall_8, + padding: spacing.small_12, + borderRadius: spacing.xSmall_8, + flexDirection: "row", + alignItems: "center", + }, + equationSection: { + marginTop: spacing.small_12, + }, + equationBody: { + backgroundColor: color.fadedOffBlack8, + border: `1px solid ${color.fadedOffBlack32}`, + marginTop: spacing.xSmall_8, + paddingLeft: spacing.xSmall_8, + paddingRight: spacing.xSmall_8, + fontSize: font.size.xSmall, + }, +}); + +export default StartCoordsSinusoid; diff --git a/packages/perseus-editor/src/components/util.ts b/packages/perseus-editor/src/components/util.ts index f585004da0..9c06bc1875 100644 --- a/packages/perseus-editor/src/components/util.ts +++ b/packages/perseus-editor/src/components/util.ts @@ -4,6 +4,7 @@ import { getLineCoords, getLinearSystemCoords, getSegmentCoords, + getSinusoidCoords, } from "@khanacademy/perseus"; import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core"; @@ -18,6 +19,7 @@ import type { LockedPolygonType, LockedFunctionType, PerseusGraphType, + Coord, } from "@khanacademy/perseus"; export function focusWithChromeStickyFocusBugWorkaround(element: Element) { @@ -178,9 +180,38 @@ export function getDefaultGraphStartCoords( const radius = kvector.length( kvector.subtract(startCoords.radiusPoint, startCoords.center), ); - return {center: startCoords.center, radius}; + case "sinusoid": + return getSinusoidCoords( + {...graph, startCoords: undefined}, + range, + step, + ); default: return undefined; } } + +export const getSinusoidEquation = (startCoords: [Coord, Coord]) => { + // Get coefficients + // It's assumed that p1 is the root and p2 is the first peak + const p1 = startCoords[0]; + const p2 = startCoords[1]; + + // Resulting coefficients are canonical for this sine curve + const amplitude = p2[1] - p1[1]; + const angularFrequency = Math.PI / (2 * (p2[0] - p1[0])); + const phase = p1[0] * angularFrequency; + const verticalOffset = p1[1]; + + return ( + "y = " + + amplitude.toFixed(3) + + "sin(" + + angularFrequency.toFixed(3) + + "x - " + + phase.toFixed(3) + + ") + " + + verticalOffset.toFixed(3) + ); +}; diff --git a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx index adf10676d5..b90e75d301 100644 --- a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx +++ b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx @@ -701,6 +701,64 @@ describe("InteractiveGraphEditor", () => { mafs: { ...flags.mafs, "start-coords-ui-phase-1": shouldRender, + "start-coords-ui-phase-2": false, + }, + }, + }} + graph={{type}} + correct={{type}} + />, + { + wrapper: RenderStateRoot, + }, + ); + + // Assert + if (shouldRender) { + expect( + await screen.findByRole("button", { + name: "Use default start coordinates", + }), + ).toBeInTheDocument(); + } else { + expect( + screen.queryByRole("button", { + name: "Use default start coordinates", + }), + ).toBeNull(); + } + }, + ); + + test.each` + type | shouldRender + ${"linear"} | ${false} + ${"ray"} | ${false} + ${"linear-system"} | ${false} + ${"segment"} | ${false} + ${"circle"} | ${false} + ${"quadratic"} | ${false} + ${"sinusoid"} | ${true} + ${"polygon"} | ${false} + ${"angle"} | ${false} + ${"point"} | ${false} + `( + "should render for $type graphs if phase2 flag is on: $shouldRender", + async ({type, shouldRender}) => { + // Arrange + + // Act + render( + { graph =
{this.props.valid}
; } + const startCoordsPhase1 = + this.props.apiOptions?.flags?.mafs?.["start-coords-ui-phase-1"]; + const startCoordsPhase2 = + this.props.apiOptions?.flags?.mafs?.["start-coords-ui-phase-2"]; + + const displayStartCoordsUI = + this.props.graph && + ((startCoordsPhase1 && + startCoordsUiPhase1Types.includes(this.props.graph.type)) || + (startCoordsPhase2 && + startCoordsUiPhase2Types.includes(this.props.graph.type))); + return ( @@ -483,12 +496,7 @@ class InteractiveGraphEditor extends React.Component { )} {this.props.graph?.type && // TODO(LEMS-2228): Remove flags once this is fully released - this.props.apiOptions?.flags?.mafs?.[ - "start-coords-ui-phase-1" - ] && - startCoordsUiPhase1Types.includes( - this.props.graph.type, - ) && ( + displayStartCoordsUI && (