Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/sixty-guests-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@khanacademy/perseus": patch
"@khanacademy/perseus-core": patch
"@khanacademy/perseus-editor": patch
---

Implementation of state management logic for new Vector graph
1 change: 1 addition & 0 deletions packages/perseus-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ export {
generateIGSegmentGraph,
generateIGSinusoidGraph,
generateIGTangentGraph,
generateIGVectorGraph,
generateIGLockedPoint,
generateIGLockedLine,
generateIGLockedVector,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
PerseusGraphTypeSegment,
PerseusGraphTypeSinusoid,
PerseusGraphTypeTangent,
PerseusGraphTypeVector,
PerseusInteractiveGraphWidgetOptions,
} from "../../data-schema";

Expand Down Expand Up @@ -155,6 +156,15 @@ export function generateIGTangentGraph(
};
}

export function generateIGVectorGraph(
options?: Partial<Omit<PerseusGraphTypeVector, "type">>,
): PerseusGraphTypeVector {
return {
type: "vector",
...options,
};
}

export function generateIGLockedPoint(
options?: Partial<Omit<LockedPointType, "type">>,
): LockedPointType {
Expand Down
1 change: 1 addition & 0 deletions packages/perseus/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export {
getSegmentCoords,
getSinusoidCoords,
getTangentCoords,
getVectorCoords,
getQuadraticCoords,
getAngleCoords,
} from "./widgets/interactive-graphs/reducer/initialize-graph-state";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -735,6 +743,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -575,3 +575,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: diagonal vector offset from axes
expect(graph.coords).toEqual([
[-5, 1],
[5, 5],
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
PerseusGraphTypeSinusoid,
PerseusGraphTypeExponential,
PerseusGraphTypeTangent,
PerseusGraphTypeVector,
} from "@khanacademy/perseus-core";
import type {Interval} from "mafs";

Expand Down Expand Up @@ -147,7 +148,11 @@ export function initializeGraphState(
coords: getTangentCoords(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);
}
Expand Down Expand Up @@ -303,6 +308,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: diagonal vector in the upper portion of the graph,
// offset from axes so it's clearly visible.
return normalizePoints(range, step, [
[0.25, 0.55],
[0.75, 0.75],
]);
}

export function getLinearSystemCoords(
graph: PerseusGraphTypeLinearSystem,
range: [x: Interval, y: Interval],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ export const actions = {
tangent: {
movePoint,
},
vector: {
moveTip: (destination: vec.Vector2) => movePoint(1, destination),
moveVector: (delta: vec.Vector2) => moveLine(0, delta),
},
};

export const DELETE_INTENT = "delete-intent";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
InteractiveGraphState,
PolygonGraphState,
TangentGraphState,
VectorGraphState,
} from "../types";
import type {GraphRange} from "@khanacademy/perseus-core";

Expand Down Expand Up @@ -1967,3 +1968,130 @@ describe("moveCenter on an exponential graph (asymptote)", () => {
expect(updated.asymptote).toBe(-2);
});
});

function generateVectorGraphState(
overrides?: Partial<Omit<VectorGraphState, "type">>,
): VectorGraphState {
return {
hasBeenInteractedWith: false,
type: "vector",
range: [
[-10, 10],
[-10, 10],
],
snapStep: [1, 1],
coords: [
[0, 0],
[3, 4],
],
...overrides,
};
}

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
const state = generateVectorGraphState();

// Act
const updated = interactiveGraphReducer(
state,
actions.vector.moveVector([2, 1]),
);

// 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
const state = generateVectorGraphState({
hasBeenInteractedWith: false,
});

// Act
const updated = interactiveGraphReducer(
state,
actions.vector.moveVector([1, 1]),
);

// 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]),
);

// 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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,8 @@ function doMoveLine(
};
}
case "linear":
case "ray": {
case "ray":
case "vector": {
const currentLine = state.coords;
const change = getChange(currentLine, action.delta, {
snapStep,
Expand Down Expand Up @@ -654,6 +655,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];
Expand Down
Loading
Loading