diff --git a/.changeset/chilly-planets-walk.md b/.changeset/chilly-planets-walk.md new file mode 100644 index 0000000000..b0823a5925 --- /dev/null +++ b/.changeset/chilly-planets-walk.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": minor +"@khanacademy/perseus-editor": minor +--- + +[Interactive Graph Editor] Implement UI to edit start coordinates for linear and ray 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 f9c27d9ba9..c13ad0e827 100644 --- a/packages/perseus-editor/src/__stories__/flags-for-api-options.ts +++ b/packages/perseus-editor/src/__stories__/flags-for-api-options.ts @@ -11,6 +11,7 @@ export const flags = { linear: true, "linear-system": true, ray: true, + "start-coords-ui": true, "interactive-graph-locked-features-m2": true, "interactive-graph-locked-features-m2b": true, }, diff --git a/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx b/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx index 0ac497af7c..8211dc6995 100644 --- a/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx +++ b/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx @@ -122,6 +122,7 @@ export const MafsWithLockedFiguresCurrent = (): React.ReactElement => { flags: { mafs: { ...flags.mafs, + "start-coords-ui": false, "interactive-graph-locked-features-m2": false, "interactive-graph-locked-features-m2b": false, }, @@ -148,6 +149,7 @@ export const MafsWithLockedFiguresM2Flag = (): React.ReactElement => { flags: { mafs: { ...flags.mafs, + "start-coords-ui": false, "interactive-graph-locked-features-m2": true, "interactive-graph-locked-features-m2b": false, }, diff --git a/packages/perseus-editor/src/components/__tests__/start-coord-settings.test.tsx b/packages/perseus-editor/src/components/__tests__/start-coord-settings.test.tsx new file mode 100644 index 0000000000..f9b42c0d9b --- /dev/null +++ b/packages/perseus-editor/src/components/__tests__/start-coord-settings.test.tsx @@ -0,0 +1,154 @@ +import {Dependencies} from "@khanacademy/perseus"; +import {render, screen} from "@testing-library/react"; +import {userEvent as userEventLib} from "@testing-library/user-event"; +import * as React from "react"; + +import {testDependencies} from "../../../../../testing/test-dependencies"; +import StartCoordSettings from "../start-coord-settings"; + +import type {Range} from "@khanacademy/perseus"; + +const defaultProps = { + range: [ + [-10, 10], + [-10, 10], + ] satisfies [Range, Range], + step: [1, 1] satisfies [number, number], +}; +describe("StartCoordSettings", () => { + let userEvent; + beforeEach(() => { + userEvent = userEventLib.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + + jest.spyOn(Dependencies, "getDependencies").mockReturnValue( + testDependencies, + ); + }); + + test("clicking the heading toggles the settings", async () => { + // Arrange + + // Act + render( + {}} + />, + ); + + const heading = screen.getByText("Start coordinates"); + + // Assert + expect(screen.getByText("Point 1")).toBeInTheDocument(); + expect(screen.getByText("Point 2")).toBeInTheDocument(); + + await userEvent.click(heading); + + expect(screen.queryByText("Point 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Point 2")).not.toBeInTheDocument(); + + await userEvent.click(heading); + + expect(screen.getByText("Point 1")).toBeInTheDocument(); + expect(screen.getByText("Point 2")).toBeInTheDocument(); + }); + + describe.each` + type + ${"linear"} + ${"ray"} + `(`graphs with CollinearTuple startCoords`, ({type}) => { + test(`shows the start coordinates UI for ${type}`, () => { + // Arrange + + // Act + render( + {}} + />, + ); + + const resetButton = screen.getByRole("button", { + name: "Use default start coords", + }); + + // Assert + expect(screen.getByText("Start coordinates")).toBeInTheDocument(); + expect(screen.getByText("Point 1")).toBeInTheDocument(); + expect(screen.getByText("Point 2")).toBeInTheDocument(); + expect(resetButton).toBeInTheDocument(); + }); + + test.each` + segmentIndex | coord + ${0} | ${"x"} + ${0} | ${"y"} + ${1} | ${"x"} + ${1} | ${"y"} + `( + `calls onChange when $coord coord is changed (segment $segmentIndex) for type ${type}`, + async ({segmentIndex, coord}) => { + // Arrange + const onChangeMock = jest.fn(); + + // Act + render( + , + ); + + // Assert + const input = screen.getAllByRole("spinbutton", { + name: `${coord} coord`, + })[segmentIndex]; + await userEvent.clear(input); + await userEvent.type(input, "101"); + + const expectedCoords = [ + [-5, 5], + [5, 5], + ]; + expectedCoords[segmentIndex][coord === "x" ? 0 : 1] = 101; + + expect(onChangeMock).toHaveBeenLastCalledWith(expectedCoords); + }, + ); + + test(`calls onChange when reset button is clicked for type ${type}`, async () => { + // Arrange + const onChangeMock = jest.fn(); + + // Act + render( + , + ); + + // Assert + const resetButton = screen.getByRole("button", { + name: "Use default start coords", + }); + await userEvent.click(resetButton); + + expect(onChangeMock).toHaveBeenLastCalledWith([ + [-5, 5], + [5, 5], + ]); + }); + }); +}); diff --git a/packages/perseus-editor/src/components/coordinate-pair-input.tsx b/packages/perseus-editor/src/components/coordinate-pair-input.tsx index 8e07ad85d4..8a9fd01943 100644 --- a/packages/perseus-editor/src/components/coordinate-pair-input.tsx +++ b/packages/perseus-editor/src/components/coordinate-pair-input.tsx @@ -28,6 +28,12 @@ const CoordinatePairInput = (props: Props) => { coord[1].toString(), ]); + // Update the local state when the props change. (Such as when the graph + // type is changed, and the coordinates are reset.) + React.useEffect(() => { + setCoordState([coord[0].toString(), coord[1].toString()]); + }, [coord]); + function handleCoordChange(newValue, coordIndex) { // Update the local state (update the input field value). const newCoordState = [...coordState]; diff --git a/packages/perseus-editor/src/components/start-coord-settings.tsx b/packages/perseus-editor/src/components/start-coord-settings.tsx new file mode 100644 index 0000000000..983a7d7790 --- /dev/null +++ b/packages/perseus-editor/src/components/start-coord-settings.tsx @@ -0,0 +1,121 @@ +import {getLineCoords} from "@khanacademy/perseus"; +import Button from "@khanacademy/wonder-blocks-button"; +import {View} from "@khanacademy/wonder-blocks-core"; +import {Strut} from "@khanacademy/wonder-blocks-layout"; +import {color, spacing} from "@khanacademy/wonder-blocks-tokens"; +import {LabelLarge} from "@khanacademy/wonder-blocks-typography"; +import arrowCounterClockwise from "@phosphor-icons/core/bold/arrow-counter-clockwise-bold.svg"; +import {StyleSheet} from "aphrodite"; +import * as React from "react"; + +import CoordinatePairInput from "./coordinate-pair-input"; +import Heading from "./heading"; + +import type { + PerseusGraphType, + Range, + CollinearTuple, +} from "@khanacademy/perseus"; + +type Props = PerseusGraphType & { + range: [x: Range, y: Range]; + step: [x: number, y: number]; + onChange: (startCoords: CollinearTuple) => void; +}; + +type PropsInner = { + type: PerseusGraphType["type"]; + startCoords: CollinearTuple; + onChange: (startCoords: CollinearTuple) => void; +}; + +const StartCoordSettingsInner = (props: PropsInner) => { + const {type, startCoords, onChange} = props; + + // Check if coords is of type CollinearTuple + switch (type) { + case "linear": + case "ray": + return ( + <> + + Point 1 + + onChange([value, startCoords[1]]) + } + /> + + + Point 2 + + onChange([startCoords[0], value]) + } + /> + + + ); + default: + return null; + } +}; + +const StartCoordSettings = (props: Props) => { + const {type, range, step, onChange} = props; + const [isOpen, setIsOpen] = React.useState(true); + + if (type !== "linear" && type !== "ray") { + return null; + } + + const defaultStartCoords = getLineCoords({type: type}, range, step); + + return ( + + {/* Heading for the collapsible section */} + setIsOpen(!isOpen)} + /> + + {/* Start coordinates main UI */} + {isOpen && ( + <> + + + {/* Button to reset to default */} + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + tile: { + backgroundColor: color.fadedBlue8, + marginTop: spacing.xSmall_8, + padding: spacing.small_12, + borderRadius: spacing.xSmall_8, + }, +}); + +export default StartCoordSettings; diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor.tsx index 3648302844..5f33ca802a 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor.tsx @@ -23,6 +23,7 @@ import GraphPointsCountSelector from "../components/graph-points-count-selector" import GraphTypeSelector from "../components/graph-type-selector"; import InteractiveGraphSettings from "../components/interactive-graph-settings"; import SegmentCountSelector from "../components/segment-count-selector"; +import StartCoordSettings from "../components/start-coord-settings"; import {parsePointCount} from "../util/points"; import type { @@ -239,12 +240,18 @@ class InteractiveGraphEditor extends React.Component { let correct = this.props.correct; // @ts-expect-error - TS2532 - Object is possibly 'undefined'. if (correct.type === newProps.graph.type) { - correct = _.extend({}, correct, newProps.graph); + correct = { + ...correct, + ...newProps.graph, + }; } else { // Clear options from previous graph correct = newProps.graph; } - this.props.onChange({correct: correct}); + this.props.onChange({ + correct: correct, + graph: this.props.graph, + }); }, } as const; @@ -479,6 +486,16 @@ class InteractiveGraphEditor extends React.Component { /> )} + {(this.props.graph?.type === "linear" || + this.props.graph?.type === "ray") && + this.props.apiOptions.flags?.mafs?.["start-coords-ui"] && ( + + )} { this.props.onChange({correct: correct}); }; + changeStartCoords = (coords) => { + if ( + this.props.graph?.type !== "linear" && + this.props.graph?.type !== "ray" + ) { + return; + } + + const graph = { + ...this.props.graph, + startCoords: coords, + }; + this.props.onChange({graph: graph}); + }; + + // serialize() is what makes copy/paste work. All the properties included + // in the serialization json are included when, for example, a graph + // is copied from the question editor and pasted into the hint editor + // (double brackets in the markdown). serialize(): PerseusInteractiveGraphWidgetOptions { const json = _.pick( this.props, @@ -666,7 +702,10 @@ class InteractiveGraphEditor extends React.Component { // @ts-expect-error TS2339 Property 'getUserInput' does not exist on type 'ReactInstance'. Property 'getUserInput' does not exist on type 'Component'. const correct = graph && graph.getUserInput(); _.extend(json, { - graph: {type: correct.type}, + graph: { + type: correct.type, + startCoords: this.props.graph?.startCoords, + }, correct: correct, }); diff --git a/packages/perseus/src/index.ts b/packages/perseus/src/index.ts index 966999ba1b..153f09d254 100644 --- a/packages/perseus/src/index.ts +++ b/packages/perseus/src/index.ts @@ -118,6 +118,7 @@ export { } from "./widget-type-utils"; export {convertWidgetNameToEnum} from "./util/widget-enum-utils"; export {addWidget, QUESTION_WIDGETS} from "./util/snowman-utils"; +export {getLineCoords} from "./widgets/interactive-graphs/reducer/initialize-graph-state"; /** * Mixins diff --git a/packages/perseus/src/perseus-types.ts b/packages/perseus/src/perseus-types.ts index b5b6f82d3c..b475d6b47f 100644 --- a/packages/perseus/src/perseus-types.ts +++ b/packages/perseus/src/perseus-types.ts @@ -785,24 +785,32 @@ export type PerseusGraphTypeAngle = { match?: "congruent"; // must have 3 coords - ie [Coord, Coord, Coord] coords?: [Coord, Coord, Coord]; + // The initial coordinates the graph renders with. + startCoords?: [Coord, Coord, Coord]; }; export type PerseusGraphTypeCircle = { type: "circle"; center?: Coord; radius?: number; + // The initial coordinates the graph renders with. + startCoords?: Coord; } & PerseusGraphTypeCommon; export type PerseusGraphTypeLinear = { type: "linear"; // expects 2 coords coords?: CollinearTuple; + // The initial coordinates the graph renders with. + startCoords?: CollinearTuple; } & PerseusGraphTypeCommon; export type PerseusGraphTypeLinearSystem = { type: "linear-system"; // expects 2 sets of 2 coords coords?: CollinearTuple[]; + // The initial coordinates the graph renders with. + startCoords?: CollinearTuple[]; } & PerseusGraphTypeCommon; export type PerseusGraphTypePoint = { @@ -810,6 +818,8 @@ export type PerseusGraphTypePoint = { // The number of points if a "point" type. default: 1. "unlimited" if no limit numPoints?: number | "unlimited"; coords?: ReadonlyArray; + // The initial coordinates the graph renders with. + startCoords?: ReadonlyArray; } & PerseusGraphTypeCommon; export type PerseusGraphTypePolygon = { @@ -825,12 +835,16 @@ export type PerseusGraphTypePolygon = { // How to match the answer. If missing, defaults to exact matching. match?: "similar" | "congruent" | "approx"; coords?: ReadonlyArray; + // The initial coordinates the graph renders with. + startCoords?: ReadonlyArray; } & PerseusGraphTypeCommon; export type PerseusGraphTypeQuadratic = { type: "quadratic"; // expects a list of 3 coords coords?: [Coord, Coord, Coord]; + // The initial coordinates the graph renders with. + startCoords?: [Coord, Coord, Coord]; } & PerseusGraphTypeCommon; export type PerseusGraphTypeSegment = { @@ -839,18 +853,24 @@ export type PerseusGraphTypeSegment = { numSegments?: number; // Expects a list of Coord tuples. Length should match the `numSegments` value. coords?: CollinearTuple[]; + // The initial coordinates the graph renders with. + startCoords?: CollinearTuple[]; } & PerseusGraphTypeCommon; export type PerseusGraphTypeSinusoid = { type: "sinusoid"; // Expects a list of 2 Coords coords?: ReadonlyArray; + // The initial coordinates the graph renders with. + startCoords?: ReadonlyArray; } & PerseusGraphTypeCommon; export type PerseusGraphTypeRay = { type: "ray"; // Expects a list of 2 Coords coords?: CollinearTuple; + // The initial coordinates the graph renders with. + startCoords?: CollinearTuple; } & PerseusGraphTypeCommon; export type PerseusLabelImageWidgetOptions = { diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index ca2b46ebda..3ebbe92663 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -162,6 +162,13 @@ export const InteractiveGraphLockedFeaturesFlags = [ "interactive-graph-locked-features-m2b", ] as const; +export const InteractiveGraphEditorFlags = [ + /** + * Enables the UI for setting the start coordinates of a graph. + */ + "start-coords-ui", +] as const; + /** * APIOptions provides different ways to customize the behaviour of Perseus. * @@ -296,6 +303,8 @@ export type APIOptions = Readonly<{ | false | ({[Key in (typeof MafsGraphTypeFlags)[number]]?: boolean} & { [Key in (typeof InteractiveGraphLockedFeaturesFlags)[number]]?: boolean; + } & { + [Key in (typeof InteractiveGraphEditorFlags)[number]]?: boolean; }); }; /** 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 7d47efea66..8894ad74c2 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 @@ -109,7 +109,7 @@ describe("InteractiveGraphQuestionBuilder", () => { graph: { type: "segment", numSegments: 1, - coords: [ + startCoords: [ [ [0, 0], [2, 2], @@ -150,7 +150,7 @@ describe("InteractiveGraphQuestionBuilder", () => { graph: { type: "segment", numSegments: 2, - coords: [ + startCoords: [ [ [0, 0], [2, 2], @@ -211,7 +211,7 @@ describe("InteractiveGraphQuestionBuilder", () => { expect.objectContaining({ graph: { type: "linear", - coords: [ + startCoords: [ [3, 0], [3, 3], ], @@ -270,7 +270,7 @@ describe("InteractiveGraphQuestionBuilder", () => { expect.objectContaining({ graph: { type: "linear-system", - coords: [ + startCoords: [ [ [-3, 0], [-3, 3], @@ -329,7 +329,7 @@ describe("InteractiveGraphQuestionBuilder", () => { expect.objectContaining({ graph: { type: "ray", - coords: [ + startCoords: [ [3, 0], [3, 3], ], @@ -365,7 +365,7 @@ describe("InteractiveGraphQuestionBuilder", () => { const graph = question.widgets["interactive-graph 1"]; expect(graph.options).toEqual( expect.objectContaining({ - graph: {type: "circle", center: [9, 9], radius: 5}, + graph: {type: "circle", startCoords: [9, 9], radius: 5}, correct: {type: "circle", radius: 5, center: [0, 0]}, }), ); @@ -404,7 +404,7 @@ describe("InteractiveGraphQuestionBuilder", () => { expect.objectContaining({ graph: { type: "quadratic", - coords: [ + startCoords: [ [-1, -1], [0, 0], [1, -1], @@ -453,7 +453,7 @@ describe("InteractiveGraphQuestionBuilder", () => { expect.objectContaining({ graph: { type: "sinusoid", - coords: [ + startCoords: [ [0, 0], [1, -1], ], 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 5fce468431..b8aaf8a475 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 @@ -310,7 +310,7 @@ class SegmentGraphConfig implements InteractiveFigureConfig { return { type: "segment", numSegments: this.numSegments, - coords: this.startCoords, + startCoords: this.startCoords, }; } } @@ -333,7 +333,7 @@ class LinearConfig implements InteractiveFigureConfig { } graph(): PerseusGraphType { - return {type: "linear", coords: this.startCoords}; + return {type: "linear", startCoords: this.startCoords}; } } @@ -361,7 +361,7 @@ class LinearSystemConfig implements InteractiveFigureConfig { } graph(): PerseusGraphType { - return {type: "linear-system", coords: this.startCoords}; + return {type: "linear-system", startCoords: this.startCoords}; } } @@ -383,7 +383,7 @@ class RayConfig implements InteractiveFigureConfig { } graph(): PerseusGraphType { - return {type: "ray", coords: this.startCoords}; + return {type: "ray", startCoords: this.startCoords}; } } @@ -400,7 +400,7 @@ class CircleGraphConfig implements InteractiveFigureConfig { graph(): PerseusGraphType { if (this.startCoords) { - return {type: "circle", center: this.startCoords, radius: 5}; + return {type: "circle", startCoords: this.startCoords, radius: 5}; } return {type: "circle"}; @@ -426,7 +426,7 @@ class QuadraticConfig implements InteractiveFigureConfig { } graph(): PerseusGraphType { - return {type: "quadratic", coords: this.startCoords}; + return {type: "quadratic", startCoords: this.startCoords}; } } @@ -448,7 +448,7 @@ class SinusoidGraphConfig implements InteractiveFigureConfig { } graph(): PerseusGraphType { - return {type: "sinusoid", coords: this.startCoords}; + return {type: "sinusoid", startCoords: this.startCoords}; } } diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts index b02a794354..63b08a634a 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts @@ -122,6 +122,11 @@ function getPointCoords( return coords; } + const startCoords = graph.startCoords?.slice(); + if (startCoords) { + return startCoords; + } + switch (numPoints) { case 1: // Back in the day, one point's coords were in graph.coord @@ -190,6 +195,10 @@ function getSegmentCoords( return graph.coords; } + if (graph.startCoords) { + return graph.startCoords; + } + const ys = (n?: number) => { switch (n) { case 2: @@ -234,7 +243,7 @@ const defaultLinearCoords: [Coord, Coord][] = [ ], ]; -function getLineCoords( +export function getLineCoords( graph: PerseusGraphTypeRay | PerseusGraphTypeLinear, range: [x: Interval, y: Interval], step: [x: number, y: number], @@ -243,6 +252,10 @@ function getLineCoords( return graph.coords; } + if (graph.startCoords) { + return graph.startCoords; + } + return normalizePoints(range, step, defaultLinearCoords[0]); } @@ -255,6 +268,10 @@ function getLinearSystemCoords( return graph.coords; } + if (graph.startCoords) { + return graph.startCoords; + } + return defaultLinearCoords.map((points) => normalizePoints(range, step, points), ); @@ -270,6 +287,11 @@ function getPolygonCoords( return coords; } + const startCoords = graph.startCoords?.slice(); + if (startCoords) { + return startCoords; + } + const n = graph.numSides || 3; if (n === "unlimited") { @@ -309,6 +331,10 @@ function getSinusoidCoords( return [graph.coords[0], graph.coords[1]]; } + if (graph.startCoords) { + return [graph.startCoords[0], graph.startCoords[1]]; + } + let coords: [Coord, Coord] = [ [0.5, 0.5], [0.65, 0.6], @@ -328,6 +354,10 @@ function getQuadraticCoords( return graph.coords; } + if (graph.startCoords) { + return graph.startCoords; + } + const defaultCoords: [Coord, Coord, Coord] = [ [0.25, 0.75], [0.5, 0.25], @@ -347,6 +377,14 @@ function getCircleCoords(graph: PerseusGraphTypeCircle): { radiusPoint: vec.add(graph.center, [graph.radius, 0]), }; } + + if (graph.startCoords) { + return { + center: graph.startCoords, + radiusPoint: vec.add(graph.startCoords, [2, 0]), + }; + } + return { center: [0, 0], radiusPoint: [2, 0], diff --git a/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx index 1e2585e94b..2b1286936e 100644 --- a/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx @@ -99,6 +99,7 @@ export const StatefulMafsGraph = React.forwardRef< const snapTo = graph.type === "polygon" ? graph.snapTo : null; const showAngles = graph.type === "polygon" ? graph.showAngles : null; const showSides = graph.type === "polygon" ? graph.showSides : null; + const startCoords = graph.startCoords ?? null; const originalPropsRef = useRef(props); const latestPropsRef = useLatestRef(props); @@ -117,7 +118,9 @@ export const StatefulMafsGraph = React.forwardRef< snapTo, showAngles, showSides, + startCoords, latestPropsRef, + startCoords, ]); return ;