diff --git a/.changeset/metal-apricots-brake.md b/.changeset/metal-apricots-brake.md new file mode 100644 index 0000000000..5a96848802 --- /dev/null +++ b/.changeset/metal-apricots-brake.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/perseus": minor +"@khanacademy/perseus-editor": minor +--- + +[Interactive Graph] View a locked polygon diff --git a/packages/perseus-editor/src/components/__tests__/util.test.ts b/packages/perseus-editor/src/components/__tests__/util.test.ts index 54a2642f79..fe10a6551f 100644 --- a/packages/perseus-editor/src/components/__tests__/util.test.ts +++ b/packages/perseus-editor/src/components/__tests__/util.test.ts @@ -37,6 +37,18 @@ describe("getDefaultFigureForType", () => { }); }); + test("should return a vector with default values", () => { + const figure = getDefaultFigureForType("vector"); + expect(figure).toEqual({ + type: "vector", + points: [ + [0, 0], + [2, 2], + ], + color: "grayH", + }); + }); + test("should return an ellipse with default values", () => { const figure = getDefaultFigureForType("ellipse"); expect(figure).toEqual({ @@ -50,15 +62,19 @@ describe("getDefaultFigureForType", () => { }); }); - test("should return a vector with default values", () => { - const figure = getDefaultFigureForType("vector"); + test("should return a polygon with default values", () => { + const figure = getDefaultFigureForType("polygon"); expect(figure).toEqual({ - type: "vector", + type: "polygon", points: [ - [0, 0], - [2, 2], + [0, 2], + [-1, 0], + [1, 0], ], color: "grayH", + showVertices: false, + fillStyle: "none", + strokeStyle: "solid", }); }); }); diff --git a/packages/perseus-editor/src/components/ellipse-swatch.tsx b/packages/perseus-editor/src/components/ellipse-swatch.tsx index 6808c74556..3218ee7e82 100644 --- a/packages/perseus-editor/src/components/ellipse-swatch.tsx +++ b/packages/perseus-editor/src/components/ellipse-swatch.tsx @@ -1,7 +1,4 @@ -import { - lockedFigureColors, - lockedEllipseFillStyles, -} from "@khanacademy/perseus"; +import {lockedFigureColors, lockedFigureFillStyles} from "@khanacademy/perseus"; import {View} from "@khanacademy/wonder-blocks-core"; import {color as wbColor, spacing} from "@khanacademy/wonder-blocks-tokens"; import {StyleSheet} from "aphrodite"; @@ -9,12 +6,12 @@ import * as React from "react"; import type { LockedFigureColor, - LockedEllipseFillType, + LockedFigureFillType, } from "@khanacademy/perseus"; type Props = { color: LockedFigureColor; - fillStyle: LockedEllipseFillType; + fillStyle: LockedFigureFillType; strokeStyle: "solid" | "dashed"; }; @@ -36,7 +33,7 @@ const EllipseSwatch = (props: Props) => { styles.innerCircle, { backgroundColor: lockedFigureColors[color], - opacity: lockedEllipseFillStyles[fillStyle], + opacity: lockedFigureFillStyles[fillStyle], }, ]} /> diff --git a/packages/perseus-editor/src/components/locked-ellipse-settings.tsx b/packages/perseus-editor/src/components/locked-ellipse-settings.tsx index f620167815..fee8933a8a 100644 --- a/packages/perseus-editor/src/components/locked-ellipse-settings.tsx +++ b/packages/perseus-editor/src/components/locked-ellipse-settings.tsx @@ -1,4 +1,4 @@ -import {components, lockedEllipseFillStyles} from "@khanacademy/perseus"; +import {components, lockedFigureFillStyles} from "@khanacademy/perseus"; import {View} from "@khanacademy/wonder-blocks-core"; import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown"; import {Strut} from "@khanacademy/wonder-blocks-layout"; @@ -17,7 +17,7 @@ import LockedFigureSettingsActions from "./locked-figure-settings-actions"; import type {AccordionProps} from "./locked-figure-settings"; import type { Coord, - LockedEllipseFillType, + LockedFigureFillType, LockedEllipseType, LockedFigureColor, } from "@khanacademy/perseus"; @@ -118,13 +118,13 @@ const LockedEllipseSettings = (props: Props) => { + onChange={(value: LockedFigureFillType) => onChangeProps({fillStyle: value}) } // Placeholder is required, but never gets used. placeholder="" > - {Object.keys(lockedEllipseFillStyles).map((option) => ( + {Object.keys(lockedFigureFillStyles).map((option) => ( { return ( {figures?.map((figure, index) => { + if (figure.type === "polygon") { + // TODO(LEMS-1943): Implement locked polygon settings. + // Remove this block once locked polygon settings are + // implemented. + return; + } + return ( = { export type LockedFigure = | LockedPointType | LockedLineType + | LockedVectorType | LockedEllipseType - | LockedVectorType; + | LockedPolygonType; export type LockedFigureType = LockedFigure["type"]; export type LockedPointType = { @@ -698,8 +699,14 @@ export type LockedLineType = { showPoint2: boolean; }; -export type LockedEllipseFillType = "none" | "solid" | "translucent"; -export const lockedEllipseFillStyles: Record = { +export type LockedVectorType = { + type: "vector"; + points: [tail: Coord, tip: Coord]; + color: LockedFigureColor; +}; + +export type LockedFigureFillType = "none" | "solid" | "translucent"; +export const lockedFigureFillStyles: Record = { none: 0, solid: 1, translucent: 0.4, @@ -711,14 +718,17 @@ export type LockedEllipseType = { radius: [x: number, y: number]; angle: number; color: LockedFigureColor; - fillStyle: LockedEllipseFillType; + fillStyle: LockedFigureFillType; strokeStyle: "solid" | "dashed"; }; -export type LockedVectorType = { - type: "vector"; - points: [tail: Coord, tip: Coord]; +export type LockedPolygonType = { + type: "polygon"; + points: ReadonlyArray; color: LockedFigureColor; + showVertices: boolean; + fillStyle: LockedFigureFillType; + strokeStyle: "solid" | "dashed"; }; export type PerseusGraphType = diff --git a/packages/perseus/src/widgets/__stories__/interactive-graph.stories.tsx b/packages/perseus/src/widgets/__stories__/interactive-graph.stories.tsx index ac89e6471c..85c5ff2fc1 100644 --- a/packages/perseus/src/widgets/__stories__/interactive-graph.stories.tsx +++ b/packages/perseus/src/widgets/__stories__/interactive-graph.stories.tsx @@ -20,6 +20,7 @@ import { sinusoidQuestion, segmentWithLockedEllipses, segmentWithLockedVectors, + segmentWithLockedPolygons, } from "../__testdata__/interactive-graph.testdata"; export default { @@ -117,6 +118,10 @@ export const AllLockedRays = (args: StoryArgs): React.ReactElement => ( /> ); +export const LockedVector = (args: StoryArgs): React.ReactElement => ( + +); + export const LockedEllipse = (args: StoryArgs): React.ReactElement => ( ( /> ); -export const LockedVector = (args: StoryArgs): React.ReactElement => ( - +export const LockedPolygon = (args: StoryArgs): React.ReactElement => ( + ); export const Sinusoid = (args: StoryArgs): React.ReactElement => ( diff --git a/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts b/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts index 6a6217efd7..1dbbbbf466 100644 --- a/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts +++ b/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts @@ -2089,6 +2089,12 @@ export const segmentWithAllLockedRayVariations: PerseusRenderer = { }, }; +export const segmentWithLockedVectors: PerseusRenderer = + interactiveGraphQuestionBuilder() + .addLockedVector([0, 0], [2, 2]) + .addLockedVector([2, 2], [-2, 4], "green") + .build(); + export const segmentWithLockedEllipses: PerseusRenderer = interactiveGraphQuestionBuilder() .addLockedEllipse([0, 0], [5, 5]) @@ -2106,10 +2112,42 @@ export const segmentWithLockedEllipses: PerseusRenderer = }) .build(); -export const segmentWithLockedVectors: PerseusRenderer = +export const segmentWithLockedPolygons: PerseusRenderer = interactiveGraphQuestionBuilder() - .addLockedVector([0, 0], [2, 2]) - .addLockedVector([2, 2], [-2, 4], "green") + .addLockedPolygon([ + [-3, 4], + [-5, 1], + [-1, 1], + ]) + .addLockedPolygon( + [ + [1, 4], + [4, 4], + [4, 1], + [1, 1], + ], + { + color: "green", + showVertices: true, + fillStyle: "translucent", + strokeStyle: "dashed", + }, + ) + .addLockedPolygon( + [ + [0, -1], + [-2, -3], + [-1, -5], + [1, -5], + [2, -3], + ], + { + color: "purple", + showVertices: false, + fillStyle: "solid", + strokeStyle: "solid", + }, + ) .build(); export const segmentWithLockedFigures: PerseusRenderer = diff --git a/packages/perseus/src/widgets/__tests__/interactive-graph.test.ts b/packages/perseus/src/widgets/__tests__/interactive-graph.test.ts index 3c263f3c2a..7e4e1199dc 100644 --- a/packages/perseus/src/widgets/__tests__/interactive-graph.test.ts +++ b/packages/perseus/src/widgets/__tests__/interactive-graph.test.ts @@ -31,6 +31,7 @@ import { segmentWithLockedLineQuestion, segmentWithLockedPointsQuestion, segmentWithLockedPointsWithColorQuestion, + segmentWithLockedPolygons, segmentWithLockedVectors, sinusoidQuestionWithDefaultCorrect, } from "../__testdata__/interactive-graph.testdata"; @@ -538,6 +539,54 @@ describe("locked layer", () => { }); }); + test("should render locked vectors", async () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedVectors, { + flags: { + mafs: { + segment: true, + }, + }, + }); + + // Act + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const vectors = container.querySelectorAll(".locked-vector"); + + // Assert + expect(vectors).toHaveLength(2); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + let vector = vectors[0].children[0]; + expect(vector).toHaveStyle({ + "stroke-width": "2", + stroke: lockedFigureColors["grayH"], + }); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + let arrowheads = vector.querySelectorAll( + ".interactive-graph-arrowhead", + ); + expect(arrowheads).toHaveLength(1); + // Arrowhead should be at the end (tip) of the vector, and rotated + expect(arrowheads[0]).toHaveAttribute( + "transform", + "translate(40 -40) rotate(-45)", + ); + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + vector = vectors[1].children[0]; + expect(vector).toHaveStyle({ + "stroke-width": "2", + stroke: lockedFigureColors["green"], + }); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + arrowheads = vector.querySelectorAll(".interactive-graph-arrowhead"); + expect(arrowheads).toHaveLength(1); + expect(arrowheads[0]).toHaveAttribute( + "transform", + "translate(-40 -80) rotate(-153.43494882292202)", + ); + }); + test("should render locked ellipses", async () => { // Arrange const {container} = renderQuestion(segmentWithLockedEllipses, { @@ -568,9 +617,9 @@ describe("locked layer", () => { }); }); - test("should render locked vectors", async () => { + test("should render locked polygons with style", async () => { // Arrange - const {container} = renderQuestion(segmentWithLockedVectors, { + const {container} = renderQuestion(segmentWithLockedPolygons, { flags: { mafs: { segment: true, @@ -580,39 +629,56 @@ describe("locked layer", () => { // Act // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - const vectors = container.querySelectorAll(".locked-vector"); + const polygons = container.querySelectorAll(".locked-polygon polygon"); // Assert - expect(vectors).toHaveLength(2); - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - let vector = vectors[0].children[0]; - expect(vector).toHaveStyle({ - "stroke-width": "2", + expect(polygons).toHaveLength(3); + expect(polygons[0]).toHaveStyle({ + "fill-opacity": "0", stroke: lockedFigureColors["grayH"], }); - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - let arrowheads = vector.querySelectorAll( - ".interactive-graph-arrowhead", - ); - expect(arrowheads).toHaveLength(1); - // Arrowhead should be at the end (tip) of the vector, and rotated - expect(arrowheads[0]).toHaveAttribute( - "transform", - "translate(40 -40) rotate(-45)", - ); - - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - vector = vectors[1].children[0]; - expect(vector).toHaveStyle({ - "stroke-width": "2", + expect(polygons[1]).toHaveStyle({ + "fill-opacity": "0.4", stroke: lockedFigureColors["green"], }); + expect(polygons[2]).toHaveStyle({ + "fill-opacity": "1", + stroke: lockedFigureColors["purple"], + }); + }); + + test("should render vertices of locked polygons with showVertices", async () => { + // Arrange + const {container} = renderQuestion(segmentWithLockedPolygons, { + flags: { + mafs: { + segment: true, + }, + }, + }); + + // Act // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - arrowheads = vector.querySelectorAll(".interactive-graph-arrowhead"); - expect(arrowheads).toHaveLength(1); - expect(arrowheads[0]).toHaveAttribute( - "transform", - "translate(-40 -80) rotate(-153.43494882292202)", + const polygonVertices = container.querySelectorAll( + ".locked-polygon circle", ); + + // Assert + // There should be 4 vertices on the square polygon + expect(polygonVertices).toHaveLength(4); + + // The square polygon is green + expect(polygonVertices[0]).toHaveStyle({ + fill: lockedFigureColors["green"], + }); + expect(polygonVertices[1]).toHaveStyle({ + fill: lockedFigureColors["green"], + }); + expect(polygonVertices[2]).toHaveStyle({ + fill: lockedFigureColors["green"], + }); + expect(polygonVertices[3]).toHaveStyle({ + fill: lockedFigureColors["green"], + }); }); }); diff --git a/packages/perseus/src/widgets/interactive-graphs/graph-locked-layer.tsx b/packages/perseus/src/widgets/interactive-graphs/graph-locked-layer.tsx index adf284eb42..9df183a77f 100644 --- a/packages/perseus/src/widgets/interactive-graphs/graph-locked-layer.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/graph-locked-layer.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import LockedEllipse from "./locked-ellipse"; import LockedLine from "./locked-line"; import LockedPoint from "./locked-point"; +import LockedPolygon from "./locked-polygon"; import LockedVector from "./locked-vector"; import type {LockedFigure} from "../../perseus-types"; @@ -32,6 +33,10 @@ const GraphLockedLayer = (props: Props) => { {...figure} /> ); + case "vector": + return ( + + ); case "ellipse": return ( { {...figure} /> ); - case "vector": + case "polygon": return ( - + ); default: /** 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 48b88f9d8d..755867bc14 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 @@ -202,6 +202,42 @@ describe("InteractiveGraphQuestionBuilder", () => { ]); }); + it("adds a locked vector", () => { + const question: PerseusRenderer = interactiveGraphQuestionBuilder() + .addLockedVector([1, 2], [3, 4]) + .build(); + const graph = question.widgets["interactive-graph 1"]; + + expect(graph.options.lockedFigures).toEqual([ + { + type: "vector", + points: [ + [1, 2], + [3, 4], + ], + color: "grayH", + }, + ]); + }); + + it("adds a locked vector with a specified color", () => { + const question: PerseusRenderer = interactiveGraphQuestionBuilder() + .addLockedVector([1, 2], [3, 4], "green") + .build(); + const graph = question.widgets["interactive-graph 1"]; + + expect(graph.options.lockedFigures).toEqual([ + { + type: "vector", + points: [ + [1, 2], + [3, 4], + ], + color: "green", + }, + ]); + }); + it("adds a locked ellipse", () => { const question: PerseusRenderer = interactiveGraphQuestionBuilder() .addLockedEllipse([1, 2], [3, 3]) @@ -245,38 +281,62 @@ describe("InteractiveGraphQuestionBuilder", () => { ]); }); - it("adds a locked vector", () => { + it("adds a locked polygon", () => { const question: PerseusRenderer = interactiveGraphQuestionBuilder() - .addLockedVector([1, 2], [3, 4]) + .addLockedPolygon([ + [1, 2], + [3, 4], + [5, 6], + ]) .build(); const graph = question.widgets["interactive-graph 1"]; expect(graph.options.lockedFigures).toEqual([ { - type: "vector", + type: "polygon", points: [ [1, 2], [3, 4], + [5, 6], ], color: "grayH", + showVertices: false, + fillStyle: "none", + strokeStyle: "solid", }, ]); }); - it("adds a locked vector with a specified color", () => { + it("adds a locked polygon with options", () => { const question: PerseusRenderer = interactiveGraphQuestionBuilder() - .addLockedVector([1, 2], [3, 4], "green") + .addLockedPolygon( + [ + [1, 2], + [3, 4], + [5, 6], + ], + { + color: "green", + showVertices: true, + fillStyle: "translucent", + strokeStyle: "dashed", + }, + ) .build(); const graph = question.widgets["interactive-graph 1"]; expect(graph.options.lockedFigures).toEqual([ { - type: "vector", + type: "polygon", points: [ [1, 2], [3, 4], + [5, 6], ], color: "green", + showVertices: true, + fillStyle: "translucent", + strokeStyle: "dashed", }, ]); }); 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 9b443640f8..78022194b3 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 @@ -1,5 +1,5 @@ import type { - LockedEllipseFillType, + LockedFigureFillType, LockedEllipseType, LockedFigure, LockedFigureColor, @@ -8,6 +8,7 @@ import type { LockedVectorType, PerseusGraphType, PerseusRenderer, + LockedPolygonType, } from "../../perseus-types"; import type {Interval, vec} from "mafs"; @@ -140,13 +141,27 @@ class InteractiveGraphQuestionBuilder { return this; } + addLockedVector( + tail: vec.Vector2, + tip: vec.Vector2, + color?: LockedFigureColor, + ): InteractiveGraphQuestionBuilder { + const vector: LockedVectorType = { + type: "vector", + color: color ?? "grayH", + points: [tail, tip], + }; + this.addLockedFigure(vector); + return this; + } + addLockedEllipse( center: vec.Vector2, radius: [x: number, y: number], options?: { angle?: number; color?: LockedFigureColor; - fillStyle?: LockedEllipseFillType; + fillStyle?: LockedFigureFillType; strokeStyle?: "solid" | "dashed"; }, ): InteractiveGraphQuestionBuilder { @@ -165,17 +180,26 @@ class InteractiveGraphQuestionBuilder { return this; } - addLockedVector( - tail: vec.Vector2, - tip: vec.Vector2, - color?: LockedFigureColor, + addLockedPolygon( + points: vec.Vector2[], + options?: { + color?: LockedFigureColor; + showVertices?: boolean; + fillStyle?: LockedFigureFillType; + strokeStyle?: "solid" | "dashed"; + }, ): InteractiveGraphQuestionBuilder { - const vector: LockedVectorType = { - type: "vector", - color: color ?? "grayH", - points: [tail, tip], + const polygon: LockedPolygonType = { + type: "polygon", + points: points, + color: "grayH", + showVertices: false, + fillStyle: "none", + strokeStyle: "solid", + ...options, }; - this.addLockedFigure(vector); + + this.addLockedFigure(polygon); return this; } diff --git a/packages/perseus/src/widgets/interactive-graphs/locked-ellipse.tsx b/packages/perseus/src/widgets/interactive-graphs/locked-ellipse.tsx index 109a7d6c53..4e3756421e 100644 --- a/packages/perseus/src/widgets/interactive-graphs/locked-ellipse.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/locked-ellipse.tsx @@ -2,7 +2,7 @@ import {Ellipse} from "mafs"; import * as React from "react"; import { - lockedEllipseFillStyles, + lockedFigureFillStyles, lockedFigureColors, type LockedEllipseType, } from "../../perseus-types"; @@ -15,7 +15,7 @@ const LockedEllipse = (props: LockedEllipseType) => { center={center} radius={radius} angle={angle} - fillOpacity={lockedEllipseFillStyles[fillStyle]} + fillOpacity={lockedFigureFillStyles[fillStyle]} strokeStyle={strokeStyle} color={lockedFigureColors[color]} /> diff --git a/packages/perseus/src/widgets/interactive-graphs/locked-polygon.tsx b/packages/perseus/src/widgets/interactive-graphs/locked-polygon.tsx new file mode 100644 index 0000000000..d4735965b9 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/locked-polygon.tsx @@ -0,0 +1,32 @@ +import {Point, Polygon} from "mafs"; +import * as React from "react"; + +import {lockedFigureColors, lockedFigureFillStyles} from "../../perseus-types"; + +import type {LockedPolygonType} from "../../perseus-types"; + +const LockedPolygon = (props: LockedPolygonType) => { + const {points, color, showVertices, fillStyle, strokeStyle} = props; + + return ( + + + {showVertices && + points.map((point, index) => ( + + ))} + + ); +}; + +export default LockedPolygon;