diff --git a/.changeset/sixty-guests-punch.md b/.changeset/sixty-guests-punch.md new file mode 100644 index 00000000000..70de29e553d --- /dev/null +++ b/.changeset/sixty-guests-punch.md @@ -0,0 +1,7 @@ +--- +"@khanacademy/perseus": patch +"@khanacademy/perseus-core": patch +"@khanacademy/perseus-editor": patch +--- + +Implementation of state management logic for new Vector graph diff --git a/packages/perseus-core/src/index.ts b/packages/perseus-core/src/index.ts index ba473eaeee4..c758b0fc60e 100644 --- a/packages/perseus-core/src/index.ts +++ b/packages/perseus-core/src/index.ts @@ -383,6 +383,7 @@ export { generateIGSegmentGraph, generateIGSinusoidGraph, generateIGTangentGraph, + generateIGVectorGraph, generateIGLockedPoint, generateIGLockedLine, generateIGLockedVector, diff --git a/packages/perseus-core/src/utils/generators/interactive-graph-widget-generator.ts b/packages/perseus-core/src/utils/generators/interactive-graph-widget-generator.ts index 364265715f2..a578812ec22 100644 --- a/packages/perseus-core/src/utils/generators/interactive-graph-widget-generator.ts +++ b/packages/perseus-core/src/utils/generators/interactive-graph-widget-generator.ts @@ -23,6 +23,7 @@ import type { PerseusGraphTypeSegment, PerseusGraphTypeSinusoid, PerseusGraphTypeTangent, + PerseusGraphTypeVector, PerseusInteractiveGraphWidgetOptions, } from "../../data-schema"; @@ -165,6 +166,15 @@ export function generateIGTangentGraph( }; } +export function generateIGVectorGraph( + options?: Partial>, +): PerseusGraphTypeVector { + return { + type: "vector", + ...options, + }; +} + export function generateIGLockedPoint( options?: Partial>, ): LockedPointType { diff --git a/packages/perseus/src/index.ts b/packages/perseus/src/index.ts index 5d16fb8c981..a90cfdfed07 100644 --- a/packages/perseus/src/index.ts +++ b/packages/perseus/src/index.ts @@ -150,6 +150,7 @@ export { getSegmentCoords, getSinusoidCoords, getTangentCoords, + getVectorCoords, getQuadraticCoords, getAngleCoords, } from "./widgets/interactive-graphs/reducer/initialize-graph-state"; 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 0306601876a..71394113be0 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 @@ -267,6 +267,14 @@ class InteractiveGraphQuestionBuilder { return this; } + withVector(options?: { + coords?: CollinearTuple; + startCoords?: CollinearTuple; + }): InteractiveGraphQuestionBuilder { + this.interactiveFigureConfig = new VectorGraphConfig(options); + return this; + } + withCircle(options?: { center?: Coord; radius?: number; @@ -745,6 +753,30 @@ class RayGraphConfig implements InteractiveFigureConfig { } } +class VectorGraphConfig implements InteractiveFigureConfig { + private coords?: CollinearTuple; + private startCoords?: CollinearTuple; + + constructor(options?: { + coords?: CollinearTuple; + startCoords?: CollinearTuple; + }) { + this.coords = options?.coords; + this.startCoords = options?.startCoords; + } + + correct(): PerseusGraphType { + return { + type: "vector", + coords: this.coords, + }; + } + + graph(): PerseusGraphType { + return {type: "vector", startCoords: this.startCoords}; + } +} + class CircleGraphConfig implements InteractiveFigureConfig { private startCoords?: { center: Coord; diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.test.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.test.ts index 2d22af283a3..5eec7ac9b0f 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.test.ts @@ -641,3 +641,63 @@ describe("initializeGraphState for tangent graphs", () => { ]); }); }); + +describe("initializeGraphState for vector graphs", () => { + it("uses the given coords if present", () => { + // Arrange, Act + const graph = initializeGraphState({ + ...baseGraphData, + graph: { + type: "vector", + coords: [ + [0, 0], + [3, 4], + ], + }, + }); + + // Assert + invariant(graph.type === "vector"); + expect(graph.coords).toEqual([ + [0, 0], + [3, 4], + ]); + }); + + it("uses startCoords if given and explicit coords are absent", () => { + // Arrange, Act + const graph = initializeGraphState({ + ...baseGraphData, + graph: { + type: "vector", + startCoords: [ + [1, 2], + [5, 6], + ], + }, + }); + + // Assert + invariant(graph.type === "vector"); + expect(graph.coords).toEqual([ + [1, 2], + [5, 6], + ]); + }); + + it("uses default coords if neither coords nor startCoords are given", () => { + // Arrange, Act + const graph = initializeGraphState({ + ...baseGraphData, + graph: {type: "vector"}, + }); + + // Assert + invariant(graph.type === "vector"); + // Default: 45° diagonal vector in the upper-right area of the graph + expect(graph.coords).toEqual([ + [2, 2], + [7, 7], + ]); + }); +}); 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 c7e04382405..299bf5b8c74 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 @@ -22,6 +22,7 @@ import type { PerseusGraphTypeExponential, PerseusGraphTypeTangent, PerseusGraphTypeLogarithm, + PerseusGraphTypeVector, } from "@khanacademy/perseus-core"; import type {Interval} from "mafs"; @@ -154,7 +155,11 @@ export function initializeGraphState( ...getLogarithmCoords(graph, range, step), }; case "vector": - throw new Error("Not implemented"); + return { + ...shared, + type: graph.type, + coords: getVectorCoords(graph, range, step), + }; default: throw new UnreachableCaseError(graph); } @@ -310,6 +315,27 @@ export function getLineCoords( return normalizePoints(range, step, defaultLinearCoords[0]); } +export function getVectorCoords( + graph: PerseusGraphTypeVector, + range: [x: Interval, y: Interval], + step: [x: number, y: number], +): PairOfPoints { + if (graph.coords) { + return graph.coords; + } + + if (graph.startCoords) { + return graph.startCoords; + } + + // Default: 45° diagonal vector in the upper-right area of the graph. + // Equal x/y offsets ensure a true 45° angle on a square grid. + return normalizePoints(range, step, [ + [0.6, 0.6], + [0.85, 0.85], + ]); +} + export function getLinearSystemCoords( graph: PerseusGraphTypeLinearSystem, range: [x: Interval, y: Interval], 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 db755cf6a2d..e2a68137635 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 @@ -95,6 +95,11 @@ export const actions = { tangent: { movePoint, }, + vector: { + moveTip: (destination: vec.Vector2) => movePoint(1, destination), + moveVector: (newStart: vec.Vector2, newEnd: vec.Vector2) => + moveLine(0, newStart, newEnd), + }, }; export const DELETE_INTENT = "delete-intent"; diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.test.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.test.ts index 7078dd9f709..468137a0623 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.test.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.test.ts @@ -12,6 +12,7 @@ import type { PolygonGraphState, TangentGraphState, LogarithmGraphState, + VectorGraphState, } from "../types"; import type {GraphRange} from "@khanacademy/perseus-core"; @@ -1990,6 +1991,25 @@ function generateLogarithmGraphState( }; } +function generateVectorGraphState( + overrides?: Partial>, +): VectorGraphState { + return { + hasBeenInteractedWith: false, + type: "vector", + range: [ + [-10, 10], + [-10, 10], + ], + snapStep: [1, 1], + coords: [ + [0, 0], + [3, 4], + ], + ...overrides, + }; +} + describe("movePoint on a logarithm graph", () => { it("rejects the move when both points would share the same y-coordinate", () => { // Arrange — point 0 at y=-3, point 1 at y=-7; trying to move point 0 to y=-7 @@ -2201,3 +2221,111 @@ describe("moveCenter on a logarithm graph (asymptote)", () => { expect(updated.asymptote).toBe(0); }); }); + +describe("moveTip on a vector graph", () => { + it("moves the tip to the new coordinates", () => { + // Arrange + const state = generateVectorGraphState(); + + // Act + const updated = interactiveGraphReducer( + state, + actions.vector.moveTip([5, 6]), + ); + + // Assert + invariant(updated.type === "vector"); + expect(updated.coords[1]).toEqual([5, 6]); + // Tail should remain unchanged + expect(updated.coords[0]).toEqual([0, 0]); + }); + + it("sets hasBeenInteractedWith after a move", () => { + // Arrange + const state = generateVectorGraphState({ + hasBeenInteractedWith: false, + }); + + // Act + const updated = interactiveGraphReducer( + state, + actions.vector.moveTip([5, 6]), + ); + + // Assert + expect(updated.hasBeenInteractedWith).toBe(true); + }); + + it("rejects the move when tip would overlap with tail", () => { + // Arrange — tail at [0, 0]; trying to move tip to [0, 0] + const state = generateVectorGraphState(); + + // Act + const updated = interactiveGraphReducer( + state, + actions.vector.moveTip([0, 0]), + ); + + // Assert — move was rejected; tip stays at original position + invariant(updated.type === "vector"); + expect(updated.coords[1]).toEqual([3, 4]); + }); +}); + +describe("moveVector on a vector graph (body translation)", () => { + it("translates both tail and tip by the same delta", () => { + // Arrange — default tail [0,0], tip [3,4]; delta [2,1] + const state = generateVectorGraphState(); + + // Act + const updated = interactiveGraphReducer( + state, + actions.vector.moveVector([2, 1], [5, 5]), + ); + + // Assert + invariant(updated.type === "vector"); + expect(updated.coords[0]).toEqual([2, 1]); + expect(updated.coords[1]).toEqual([5, 5]); + }); + + it("sets hasBeenInteractedWith after a body drag", () => { + // Arrange — default tail [0,0], tip [3,4]; delta [1,1] + const state = generateVectorGraphState({ + hasBeenInteractedWith: false, + }); + + // Act + const updated = interactiveGraphReducer( + state, + actions.vector.moveVector([1, 1], [4, 5]), + ); + + // Assert + expect(updated.hasBeenInteractedWith).toBe(true); + }); + + it("constrains the translation so neither point leaves the graph bounds", () => { + // Arrange — tail at [0,0], tip at [3,4], range [-10,10] + // Try to move by [8, 8] — tip would go to [11, 12] which is out of bounds + const state = generateVectorGraphState(); + + // Act + const updated = interactiveGraphReducer( + state, + actions.vector.moveVector([8, 8], [11, 12]), + ); + + // Assert — both points should be within bounds + invariant(updated.type === "vector"); + const [tail, tip] = updated.coords; + expect(tail[0]).toBeGreaterThanOrEqual(-10); + expect(tail[0]).toBeLessThanOrEqual(10); + expect(tail[1]).toBeGreaterThanOrEqual(-10); + expect(tail[1]).toBeLessThanOrEqual(10); + expect(tip[0]).toBeGreaterThanOrEqual(-10); + expect(tip[0]).toBeLessThanOrEqual(10); + expect(tip[1]).toBeGreaterThanOrEqual(-10); + expect(tip[1]).toBeLessThanOrEqual(10); + }); +}); 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 40e70e41ba6..141fa8c0160 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 @@ -358,7 +358,8 @@ function doMoveLine( }; } case "linear": - case "ray": { + case "ray": + case "vector": { return { ...state, type: state.type, @@ -714,6 +715,27 @@ function doMovePoint( }), }; } + case "vector": { + const boundDestination = boundAndSnapToGrid( + action.destination, + state, + ); + + // Reject the move if the tip would overlap with the tail + if (vec.dist(boundDestination, state.coords[0]) === 0) { + return state; + } + + return { + ...state, + hasBeenInteractedWith: true, + coords: setAtIndex({ + array: state.coords, + index: action.index, + newValue: boundDestination, + }), + }; + } case "quadratic": { // Set up the new coords and check if the quadratic coefficients are valid const newCoords: QuadraticCoords = [...state.coords]; diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.ts index 9bdd44860d6..97f9cd4f12c 100644 --- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.ts +++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-state.ts @@ -40,6 +40,13 @@ export function getGradableGraph( }; } + if (state.type === "vector" && initialGraph.type === "vector") { + return { + ...initialGraph, + coords: state.coords, + }; + } + if (state.type === "polygon" && initialGraph.type === "polygon") { // Unless the polygon is closed it is not considered score-able. if (state.numSides === "unlimited" && !state.closedPolygon) { diff --git a/packages/perseus/src/widgets/interactive-graphs/types.ts b/packages/perseus/src/widgets/interactive-graphs/types.ts index 1feddddc870..82aca16df83 100644 --- a/packages/perseus/src/widgets/interactive-graphs/types.ts +++ b/packages/perseus/src/widgets/interactive-graphs/types.ts @@ -92,7 +92,7 @@ export interface RayGraphState extends InteractiveGraphStateCommon { coords: PairOfPoints; } -interface VectorGraphState extends InteractiveGraphStateCommon { +export interface VectorGraphState extends InteractiveGraphStateCommon { type: "vector"; coords: PairOfPoints; }