diff --git a/.changeset/big-ties-fail.md b/.changeset/big-ties-fail.md new file mode 100644 index 0000000000..1010466ab9 --- /dev/null +++ b/.changeset/big-ties-fail.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/perseus": minor +--- + +Adds unlimited point graph diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index 0e674b6802..03b2cbacb9 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -142,6 +142,8 @@ export const MafsGraphTypeFlags = [ "sinusoid", /** Enables the `point` interactive-graph type with a fixed number of points. */ "point", + /** Enable the `unlimited-point` interactive graph type */ + "unlimited-point", ] as const; export const InteractiveGraphLockedFeaturesFlags = [ diff --git a/packages/perseus/src/widgets/__tests__/interactive-graph.test.tsx b/packages/perseus/src/widgets/__tests__/interactive-graph.test.tsx index 8c3a2a0eb2..aa40acc71e 100644 --- a/packages/perseus/src/widgets/__tests__/interactive-graph.test.tsx +++ b/packages/perseus/src/widgets/__tests__/interactive-graph.test.tsx @@ -181,6 +181,7 @@ describe("a mafs graph", () => { circle: circleQuestion, quadratic: quadraticQuestion, sinusoid: sinusoidQuestion, + "unlimited-point": pointQuestion, }; const graphQuestionRenderersCorrect: { @@ -196,6 +197,7 @@ describe("a mafs graph", () => { circle: circleQuestionWithDefaultCorrect, quadratic: quadraticQuestionWithDefaultCorrect, sinusoid: sinusoidQuestionWithDefaultCorrect, + "unlimited-point": pointQuestionWithDefaultCorrect, }; describe.each(Object.entries(graphQuestionRenderers))( diff --git a/packages/perseus/src/widgets/interactive-graph.tsx b/packages/perseus/src/widgets/interactive-graph.tsx index 93a7f1a0a5..02dbf22a45 100644 --- a/packages/perseus/src/widgets/interactive-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graph.tsx @@ -2666,10 +2666,7 @@ export function shouldUseMafs( switch (graph.type) { case "point": if (graph.numPoints === UNLIMITED) { - // TODO(benchristel): add a feature flag for the "unlimited" - // case once we've implemented point graphs with unlimited - // points - return false; + return Boolean(mafsFlags["unlimited-point"]); } return Boolean(mafsFlags["point"]); case "polygon": diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx index 92a12cb5dd..1420b45826 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx @@ -1,17 +1,77 @@ import * as React from "react"; import {actions} from "../reducer/interactive-graph-action"; +import useGraphConfig from "../reducer/use-graph-config"; import {MovablePoint} from "./components/movable-point"; +import { + useTransformDimensionsToPixels, + useTransformVectorsToPixels, + pixelsToVectors, +} from "./use-transform"; import type {PointGraphState, MafsGraphProps} from "../types"; type PointGraphProps = MafsGraphProps; -export function PointGraph(props: PointGraphProps) { +export function LimitedPointGraph(props: PointGraphProps) { + const {dispatch} = props; + + return ( + <> + {props.graphState.coords.map((point, i) => ( + + dispatch(actions.pointGraph.movePoint(i, destination)) + } + /> + ))} + + ); +} + +export function UnlimitedPointGraph(props: PointGraphProps) { const {dispatch} = props; + const graphState = useGraphConfig(); + const { + range: [[minX, maxX], [minY, maxY]], + } = graphState; + const width = maxX - minX; + const height = maxY - minY; + const [[widthPx, heightPx]] = useTransformDimensionsToPixels([ + width, + height, + ]); + const [[left, top]] = useTransformVectorsToPixels([minX, maxY]); return ( <> + {/* This rect is here to grab clicks so that new points can be added */} + {/* It's important because it stops mouse events from propogating + when dragging a points around */} + { + const elementRect = + event.currentTarget.getBoundingClientRect(); + + const x = event.clientX - elementRect.x; + const y = event.clientY - elementRect.y; + + const graphCoordinates = pixelsToVectors( + [[x, y]], + graphState, + ); + dispatch(actions.pointGraph.addPoint(graphCoordinates[0])); + }} + /> {props.graphState.coords.map((point, i) => ( ); } + +export function PointGraph(props: PointGraphProps) { + const numPoints = props.graphState.numPoints; + if (numPoints === "unlimited") { + return UnlimitedPointGraph(props); + } + + return LimitedPointGraph(props); +} diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/use-transform.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/use-transform.test.tsx index 750bb9b5e3..ccd876d300 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/use-transform.test.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/use-transform.test.tsx @@ -1,5 +1,6 @@ import { dimensionsToPixels, + pixelsToVectors, pointToPixel, vectorsToPixels, } from "./use-transform"; @@ -133,3 +134,81 @@ describe("pointToPixel", () => { expect(pointToPixel([1, 1], testContext)).toEqual([20, 180]); }); }); + +describe("pixelsToVectors", () => { + it("transforms (0, 0) to the top left corner of the graph bounds", () => { + const testContext: GraphDimensions = { + range: [ + [-3, 10], + [1, 7], + ], + width: 200, + height: 200, + }; + const [[x, y]] = pixelsToVectors([[0, 0]], testContext); + expect(x).toBe(-3); + expect(y).toBe(7); + }); + + it("transforms (0, 200) to the bottom left corner of the graph bounds", () => { + const testContext: GraphDimensions = { + range: [ + [-3, 10], + [1, 7], + ], + width: 200, + height: 200, + }; + const [[x, y]] = pixelsToVectors([[0, 200]], testContext); + expect(x).toBe(-3); + expect(y).toBe(1); + }); + + it("transforms (200, 0) to the top right corner of the graph bounds", () => { + const testContext: GraphDimensions = { + range: [ + [-3, 10], + [1, 7], + ], + width: 200, + height: 200, + }; + const [[x, y]] = pixelsToVectors([[200, 0]], testContext); + expect(x).toBe(10); + expect(y).toBe(7); + }); + + it("transforms (200, 200) to the bottom right corner of the graph bounds", () => { + const testContext: GraphDimensions = { + range: [ + [-3, 10], + [1, 7], + ], + width: 200, + height: 200, + }; + const [[x, y]] = pixelsToVectors([[200, 200]], testContext); + expect(x).toBe(10); + expect(y).toBe(1); + }); + + it("transforms multiple vectors", () => { + const testContext: GraphDimensions = { + range: [ + [-3, 10], + [1, 7], + ], + width: 200, + height: 200, + }; + const [a, b] = pixelsToVectors( + [ + [200, 200], + [0, 0], + ], + testContext, + ); + expect(a).toEqual([10, 1]); + expect(b).toEqual([-3, 7]); + }); +}); diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/use-transform.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/use-transform.ts index e6149c70c5..9db5144f17 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graphs/use-transform.ts +++ b/packages/perseus/src/widgets/interactive-graphs/graphs/use-transform.ts @@ -1,5 +1,6 @@ import {vec} from "mafs"; +import {X, Y} from "../math"; import useGraphConfig from "../reducer/use-graph-config"; import type {GraphDimensions} from "../types"; @@ -51,3 +52,24 @@ export const useTransformDimensionsToPixels = (...dimens: vec.Vector2[]) => { const graphState = useGraphConfig(); return dimensionsToPixels(dimens, graphState); }; + +export function pixelsToVectors( + pixels: vec.Vector2[], + graphState: GraphDimensions, +): vec.Vector2[] { + const [[xMin, xMax], [yMin, yMax]] = graphState.range; + const {width, height} = graphState; + const xSpan = xMax - xMin; + const ySpan = yMax - yMin; + + return pixels.map((pixel): vec.Vector2 => { + const x = (pixel[X] / width) * xSpan + xMin; + const y = yMax - (pixel[Y] / height) * ySpan; + return [x, y]; + }); +} + +export const useTransformPixelsToVectors = (...pixels: vec.Vector2[]) => { + const graphState = useGraphConfig(); + return pixelsToVectors(pixels, graphState); +}; diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx index 9f25b0deb5..965e164365 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx @@ -8,7 +8,7 @@ import {testDependencies} from "../../../../../testing/test-dependencies"; import * as Dependencies from "../../dependencies"; import {MafsGraph} from "./mafs-graph"; -import {actions} from "./reducer/interactive-graph-action"; +import {actions, ADD_POINT} from "./reducer/interactive-graph-action"; import {interactiveGraphReducer} from "./reducer/interactive-graph-reducer"; import type {MafsGraphProps} from "./mafs-graph"; @@ -442,6 +442,7 @@ describe("MafsGraph", () => { const mockDispatch = jest.fn(); const state: InteractiveGraphState = { type: "point", + numPoints: 2, hasBeenInteractedWith: true, range: [ [-10, 10], @@ -688,4 +689,77 @@ describe("MafsGraph", () => { ); expect(state.coords).toEqual(expectedCoords); }); + + describe("with an unlimited-point graph", () => { + it("displays an add point button", async () => { + // Arrange + // Render the question + const mockDispatch = jest.fn(); + const state: InteractiveGraphState = { + type: "point", + numPoints: "unlimited", + hasBeenInteractedWith: true, + range: [ + [-10, 10], + [-10, 10], + ], + snapStep: [2, 2], + coords: [], + }; + + const baseMafsGraphProps = getBaseMafsGraphProps(); + + render( + , + ); + + // Act: NOTHING + + // Assert + // Make sure the button is on the page + const addPointButton = await screen.findByText("Add Point"); + expect(addPointButton).not.toBeNull(); + }); + it("adds a point when the add point button is clicked", async () => { + // Arrange + // Render the question + const mockDispatch = jest.fn(); + const state: InteractiveGraphState = { + type: "point", + numPoints: "unlimited", + hasBeenInteractedWith: true, + range: [ + [-10, 10], + [-10, 10], + ], + snapStep: [2, 2], + coords: [], + }; + + const baseMafsGraphProps: MafsGraphProps = { + ...getBaseMafsGraphProps(), + markings: "none", + }; + + render( + , + ); + + // Act: Click the button + const addPointButton = await screen.findByText("Add Point"); + await userEvent.click(addPointButton); + + expect(mockDispatch.mock.calls).toEqual([ + [{type: ADD_POINT, location: [0, 0]}], + ]); + }); + }); }); diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx index cd5e6f3f45..9420b0835d 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx @@ -1,3 +1,4 @@ +import Button from "@khanacademy/wonder-blocks-button"; import {View} from "@khanacademy/wonder-blocks-core"; import {UnreachableCaseError} from "@khanacademy/wonder-stuff-core"; import {Mafs} from "mafs"; @@ -24,6 +25,7 @@ import {PointGraph} from "./graphs/point"; import {X, Y} from "./math"; import {Protractor} from "./protractor"; import {type InteractiveGraphAction} from "./reducer/interactive-graph-action"; +import {actions} from "./reducer/interactive-graph-action"; import {GraphConfigContext} from "./reducer/use-graph-config"; import type {InteractiveGraphState, InteractiveGraphProps} from "./types"; @@ -69,80 +71,118 @@ export const MafsGraph = (props: MafsGraphProps) => { disableKeyboardInteraction: readOnly || !!props.static, }} > - - + - {props.markings === "graph" && ( - <> - - - - )} - + - {/* Svg definitions to render only once */} - - {/* Background layer */} - - {/* Locked layer */} - {props.lockedFigures && ( - + + + + )} + + {/* Svg definitions to render only once */} + + {/* Background layer */} + - )} - {/* Protractor */} - {props.showProtractor && } - {/* Interactive layer */} - {renderGraph({ - state, - dispatch, - })} - + {/* Locked layer */} + {props.lockedFigures && ( + + )} + {/* Protractor */} + {props.showProtractor && } + {/* Interactive layer */} + {renderGraph({ + state, + dispatch, + })} + + + {renderGraphControls({state, dispatch})} ); }; +const renderPointGraphControls = (props: { + state: InteractiveGraphState; + dispatch: (action: InteractiveGraphAction) => unknown; +}) => ( + +); + +const renderGraphControls = (props: { + state: InteractiveGraphState; + dispatch: (action: InteractiveGraphAction) => unknown; +}) => { + const {state, dispatch} = props; + const {type} = state; + switch (type) { + case "point": + if (state.numPoints === "unlimited") { + return renderPointGraphControls({state, dispatch}); + } + return null; + default: + return null; + } +}; + const renderGraph = (props: { state: InteractiveGraphState; dispatch: (action: InteractiveGraphAction) => unknown; diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-supported-graph-types.ts b/packages/perseus/src/widgets/interactive-graphs/mafs-supported-graph-types.ts index a4e714806d..ccd3dff71b 100644 --- a/packages/perseus/src/widgets/interactive-graphs/mafs-supported-graph-types.ts +++ b/packages/perseus/src/widgets/interactive-graphs/mafs-supported-graph-types.ts @@ -9,6 +9,7 @@ export const mafsSupportedGraphTypes = [ "circle", "quadratic", "sinusoid", + "unlimited-point", ] as const; export type MafsSupportedGraphType = (typeof mafsSupportedGraphTypes)[number]; 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 f225fd0e28..c4847d24ab 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 @@ -76,6 +76,7 @@ export function initializeGraphState( ...shared, type: graph.type, coords: getPointCoords(graph, range, step), + numPoints: graph.numPoints || 0, }; case "circle": return { diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-action.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-action.ts index 66268846d2..108af2d670 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-action.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-action.ts @@ -10,7 +10,8 @@ export type InteractiveGraphAction = | MoveCenter | MoveRadiusPoint | ChangeSnapStep - | ChangeRange; + | ChangeRange + | AddPoint; export const actions = { angle: { @@ -31,6 +32,7 @@ export const actions = { }, pointGraph: { movePoint, + addPoint, }, polygon: { movePoint, @@ -67,6 +69,18 @@ function moveLine(itemIndex: number, delta: vec.Vector2): MoveLine { }; } +export const ADD_POINT = "add-point"; +export interface AddPoint { + type: typeof ADD_POINT; + location: vec.Vector2; +} +function addPoint(location: vec.Vector2): AddPoint { + return { + type: ADD_POINT, + location, + }; +} + export const MOVE_ALL = "move-all"; export interface MoveAll { type: typeof MOVE_ALL; diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts index e253f780f3..f58c62504d 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts @@ -38,6 +38,8 @@ import { type MovePoint, type MoveRadiusPoint, REINITIALIZE, + ADD_POINT, + type AddPoint, } from "./interactive-graph-action"; import type {Coord} from "../../../interactive2/types"; @@ -74,6 +76,8 @@ export function interactiveGraphReducer( return doChangeSnapStep(state, action); case CHANGE_RANGE: return doChangeRange(state, action); + case ADD_POINT: + return doAddPoint(state, action); default: throw new UnreachableCaseError(action); } @@ -499,6 +503,31 @@ function doChangeRange( }; } +function doAddPoint( + state: InteractiveGraphState, + action: AddPoint, +): InteractiveGraphState { + if (state.type !== "point") { + return state; + } + const {snapStep} = state; + const snappedPoint = snap(snapStep, action.location); + + // Check if there's already a point in that spot + for (const point of state.coords) { + if (point[X] === snappedPoint[X] && point[Y] === snappedPoint[Y]) { + return state; + } + } + + // If there's no point in spot where we want the new point to go we add it there + return { + ...state, + hasBeenInteractedWith: true, + coords: [...state.coords, snappedPoint], + }; +} + const getDeltaVertex = ( maxMoves: vec.Vector2[], minMoves: vec.Vector2[], diff --git a/packages/perseus/src/widgets/interactive-graphs/types.ts b/packages/perseus/src/widgets/interactive-graphs/types.ts index e0f1e0d9c2..de7f8ab805 100644 --- a/packages/perseus/src/widgets/interactive-graphs/types.ts +++ b/packages/perseus/src/widgets/interactive-graphs/types.ts @@ -52,6 +52,7 @@ export interface LinearSystemGraphState extends InteractiveGraphStateCommon { export interface PointGraphState extends InteractiveGraphStateCommon { type: "point"; coords: Coord[]; + numPoints?: number | "unlimited"; } export interface RayGraphState extends InteractiveGraphStateCommon {