diff --git a/.changeset/gorgeous-boats-join.md b/.changeset/gorgeous-boats-join.md
new file mode 100644
index 00000000000..e70ca3ef602
--- /dev/null
+++ b/.changeset/gorgeous-boats-join.md
@@ -0,0 +1,7 @@
+---
+"@khanacademy/perseus": minor
+"@khanacademy/perseus-core": minor
+"@khanacademy/perseus-editor": minor
+---
+
+Add `showAngles` option to polygon locked figure
diff --git a/__docs__/sample-data.ts b/__docs__/sample-data.ts
index 05ba30dccd4..d18d3c11efd 100644
--- a/__docs__/sample-data.ts
+++ b/__docs__/sample-data.ts
@@ -90,6 +90,7 @@ export const graphExample: PerseusRenderer = {
[2, -3],
],
showVertices: false,
+ showAngles: false,
strokeStyle: "solid",
},
],
diff --git a/packages/perseus-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts
index 74bded5eb3f..07a6afcab2d 100644
--- a/packages/perseus-core/src/data-schema.ts
+++ b/packages/perseus-core/src/data-schema.ts
@@ -1055,6 +1055,7 @@ export type LockedPolygonType = {
points: Coord[];
color: LockedFigureColor;
showVertices: boolean;
+ showAngles: boolean;
fillStyle: LockedFigureFillType;
strokeStyle: LockedLineStyle;
weight: StrokeWeight;
diff --git a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/interactive-graph-widget.ts b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/interactive-graph-widget.ts
index c45fc3a6c43..70537deb545 100644
--- a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/interactive-graph-widget.ts
+++ b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/interactive-graph-widget.ts
@@ -239,6 +239,7 @@ const parseLockedPolygonType = object({
points: array(pairOfNumbers),
color: parseLockedFigureColor,
showVertices: boolean,
+ showAngles: defaulted(boolean, () => false),
fillStyle: parseLockedFigureFillType,
strokeStyle: parseLockedLineStyle,
weight: parseStrokeWeight,
diff --git a/packages/perseus-core/src/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-regression.test.ts.snap b/packages/perseus-core/src/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-regression.test.ts.snap
index 0d8ef1d4724..81584aaeafa 100644
--- a/packages/perseus-core/src/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-regression.test.ts.snap
+++ b/packages/perseus-core/src/parse-perseus-json/regression-tests/__snapshots__/parse-perseus-json-regression.test.ts.snap
@@ -6752,6 +6752,7 @@ exports[`parseAndMigratePerseusItem given interactive-graph-locked-figures-missi
-3,
],
],
+ "showAngles": false,
"showVertices": false,
"strokeStyle": "solid",
"type": "polygon",
@@ -7024,6 +7025,7 @@ exports[`parseAndMigratePerseusItem given interactive-graph-locked-figures-missi
-3,
],
],
+ "showAngles": false,
"showVertices": false,
"strokeStyle": "solid",
"type": "polygon",
@@ -20239,6 +20241,14 @@ exports[`parseAndMigratePerseusItem given transformer-widget.ts returns the same
}
`;
+exports[`parseAndMigratePerseusRenderer given basic-renderer.ts returns the same result as before 1`] = `
+{
+ "content": "Hello from a renderer fixture.",
+ "images": {},
+ "widgets": {},
+}
+`;
+
exports[`parseAndMigrateUserInputMap given the data from categorizer.ts returns the same result as before 1`] = `
{
"categorizer 1": {
@@ -20822,11 +20832,3 @@ exports[`parseAndMigrateUserInputMap given the data from table.ts returns the sa
],
}
`;
-
-exports[`parseAndMigratePerseusRenderer given basic-renderer.ts returns the same result as before 1`] = `
-{
- "content": "Hello from a renderer fixture.",
- "images": {},
- "widgets": {},
-}
-`;
diff --git a/packages/perseus-core/src/parse-perseus-json/regression-tests/item-data/interactive-graph-locked-figures-missing-weights.ts b/packages/perseus-core/src/parse-perseus-json/regression-tests/item-data/interactive-graph-locked-figures-missing-weights.ts
index 0a0ddb4f9e0..63656191fa0 100644
--- a/packages/perseus-core/src/parse-perseus-json/regression-tests/item-data/interactive-graph-locked-figures-missing-weights.ts
+++ b/packages/perseus-core/src/parse-perseus-json/regression-tests/item-data/interactive-graph-locked-figures-missing-weights.ts
@@ -146,6 +146,7 @@ export default {
],
color: "pink",
showVertices: false,
+ showAngles: false,
fillStyle: "none",
strokeStyle: "solid",
labels: [],
diff --git a/packages/perseus-core/src/utils/generators/interactive-graph-widget-generator.test.ts b/packages/perseus-core/src/utils/generators/interactive-graph-widget-generator.test.ts
index 4c41d3d9e46..a495901edf0 100644
--- a/packages/perseus-core/src/utils/generators/interactive-graph-widget-generator.test.ts
+++ b/packages/perseus-core/src/utils/generators/interactive-graph-widget-generator.test.ts
@@ -1177,6 +1177,7 @@ describe("generateIGLockedPolygon", () => {
],
color: "grayH",
showVertices: false,
+ showAngles: false,
fillStyle: "none",
strokeStyle: "solid",
weight: "medium",
@@ -1194,6 +1195,7 @@ describe("generateIGLockedPolygon", () => {
],
color: "blue",
showVertices: true,
+ showAngles: true,
fillStyle: "solid",
strokeStyle: "dashed",
weight: "medium",
@@ -1219,6 +1221,7 @@ describe("generateIGLockedPolygon", () => {
],
color: "blue",
showVertices: true,
+ showAngles: true,
fillStyle: "solid",
strokeStyle: "dashed",
weight: "medium",
diff --git a/packages/perseus-core/src/utils/get-default-figure-for-type.test.ts b/packages/perseus-core/src/utils/get-default-figure-for-type.test.ts
index a2a463ce4cf..764feff31ad 100644
--- a/packages/perseus-core/src/utils/get-default-figure-for-type.test.ts
+++ b/packages/perseus-core/src/utils/get-default-figure-for-type.test.ts
@@ -82,6 +82,7 @@ describe("getDefaultFigureForType", () => {
],
color: "grayH",
showVertices: false,
+ showAngles: false,
fillStyle: "none",
strokeStyle: "solid",
weight: "medium",
diff --git a/packages/perseus-core/src/utils/get-default-figure-for-type.ts b/packages/perseus-core/src/utils/get-default-figure-for-type.ts
index e9c6d301329..b0a9ef80af4 100644
--- a/packages/perseus-core/src/utils/get-default-figure-for-type.ts
+++ b/packages/perseus-core/src/utils/get-default-figure-for-type.ts
@@ -84,6 +84,7 @@ export function getDefaultFigureForType(type: LockedFigureType): LockedFigure {
],
color: DEFAULT_COLOR,
showVertices: false,
+ showAngles: false,
fillStyle: "none",
strokeStyle: "solid",
weight: "medium",
diff --git a/packages/perseus-editor/src/__testdata__/interactive-graph-question-builder.ts b/packages/perseus-editor/src/__testdata__/interactive-graph-question-builder.ts
index 2b4659217d6..06274f91e76 100644
--- a/packages/perseus-editor/src/__testdata__/interactive-graph-question-builder.ts
+++ b/packages/perseus-editor/src/__testdata__/interactive-graph-question-builder.ts
@@ -481,6 +481,7 @@ class InteractiveGraphQuestionBuilder {
options?: {
color?: LockedFigureColor;
showVertices?: boolean;
+ showAngles?: boolean;
fillStyle?: LockedFigureFillType;
strokeStyle?: LockedLineStyle;
weight?: StrokeWeight;
@@ -493,6 +494,7 @@ class InteractiveGraphQuestionBuilder {
points: points,
color: "grayH",
showVertices: false,
+ showAngles: false,
fillStyle: "none",
strokeStyle: "solid",
weight: options?.weight ?? "medium",
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.test.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.test.tsx
index 9d48c32a18f..7a548b63bff 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.test.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.test.tsx
@@ -742,4 +742,25 @@ describe("LockedPolygonSettings", () => {
});
});
});
+
+ test("calls onChangeProps when show angle measures switch is toggled", async () => {
+ // Arrange
+ const onChangeProps = jest.fn();
+ render(
+ ,
+ {wrapper: RenderStateRoot},
+ );
+
+ // Act
+ const toggle = screen.getByRole("switch", {
+ name: "show angle measures",
+ });
+ await userEvent.click(toggle);
+
+ // Assert
+ expect(onChangeProps).toHaveBeenCalledWith({showAngles: true});
+ });
});
diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.tsx
index 79e81febf89..275bc0d8b7f 100644
--- a/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.tsx
+++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/locked-figures/locked-polygon-settings.tsx
@@ -55,6 +55,7 @@ const LockedPolygonSettings = (props: Props) => {
points,
color,
showVertices,
+ showAngles,
fillStyle,
strokeStyle,
weight,
@@ -256,6 +257,16 @@ const LockedPolygonSettings = (props: Props) => {
style={styles.spaceUnder}
/>
+ {/* Show angle measures switch */}
+
+ onChangeProps({showAngles: newValue})
+ }
+ style={styles.spaceUnder}
+ />
+
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 7881397b37a..37a4971abcf 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
@@ -1225,6 +1225,7 @@ describe("InteractiveGraphQuestionBuilder", () => {
],
color: "grayH",
showVertices: false,
+ showAngles: false,
fillStyle: "none",
strokeStyle: "solid",
weight: "medium",
@@ -1264,6 +1265,7 @@ describe("InteractiveGraphQuestionBuilder", () => {
],
color: "green",
showVertices: true,
+ showAngles: false,
fillStyle: "translucent",
strokeStyle: "dashed",
weight: "thin",
@@ -1306,6 +1308,7 @@ describe("InteractiveGraphQuestionBuilder", () => {
],
color: "grayH",
showVertices: false,
+ showAngles: false,
fillStyle: "none",
strokeStyle: "solid",
weight: "medium",
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..5b53630c9d6 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
@@ -510,6 +510,7 @@ class InteractiveGraphQuestionBuilder {
options?: {
color?: LockedFigureColor;
showVertices?: boolean;
+ showAngles?: boolean;
fillStyle?: LockedFigureFillType;
strokeStyle?: LockedLineStyle;
weight?: StrokeWeight;
@@ -522,6 +523,7 @@ class InteractiveGraphQuestionBuilder {
points: points,
color: "grayH",
showVertices: false,
+ showAngles: false,
fillStyle: "none",
strokeStyle: "solid",
weight: options?.weight ?? "medium",
diff --git a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-polygon.test.tsx b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-polygon.test.tsx
new file mode 100644
index 00000000000..efd5df51af0
--- /dev/null
+++ b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-polygon.test.tsx
@@ -0,0 +1,157 @@
+import {render, screen} from "@testing-library/react";
+import * as React from "react";
+
+import * as Dependencies from "../../../dependencies";
+import {testDependencies} from "../../../testing/test-dependencies";
+import {MafsGraph} from "../mafs-graph";
+import {getBaseMafsGraphPropsForTests} from "../utils";
+
+import type {NoneGraphState} from "../types";
+import type {LockedPolygonType} from "@khanacademy/perseus-core";
+import type {vec} from "mafs";
+
+const baseMafsGraphProps = getBaseMafsGraphPropsForTests();
+const baseGraphState = {
+ type: "none",
+ range: [
+ [-10, 10],
+ [-10, 10],
+ ],
+ hasBeenInteractedWith: false,
+ snapStep: [1, 1],
+} satisfies NoneGraphState;
+
+const baseLockedPolygonProps = {
+ type: "polygon",
+ points: [
+ [0, 2],
+ [-1, 0],
+ [1, 0],
+ ],
+ color: "grayH",
+ showVertices: false,
+ showAngles: false,
+ fillStyle: "none",
+ strokeStyle: "solid",
+ weight: "medium",
+ labels: [],
+} satisfies LockedPolygonType;
+
+// Polygon that looks like a chevron, with a concave vertex on the left,
+// drawn from the top left.
+const concavePolygonClockwise = [
+ [-7, 5],
+ [1, 5],
+ [6, 0],
+ [1, -5],
+ [-7, -5],
+ [-2, 0], // concave vertex
+] satisfies vec.Vector2[];
+
+describe("LockedPolygon", () => {
+ beforeEach(() => {
+ jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
+ testDependencies,
+ );
+ });
+
+ it("renders the polygon element", () => {
+ // Arrange, Act
+ const {container} = render(
+ ,
+ );
+
+ // Assert
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
+ const lockedPolygon = container.querySelector(".locked-polygon");
+ expect(lockedPolygon).toBeInTheDocument();
+ });
+
+ it("does not show angle indicators when showAngles is false", () => {
+ // Arrange, Act
+ render(
+ ,
+ );
+
+ // Assert
+ const angleIndicators = screen.queryAllByText(/°/);
+ expect(angleIndicators).toHaveLength(0);
+ });
+
+ it("shows correct angles for concave polygons when points are clockwise", () => {
+ // Arrange
+
+ // Act - render with angles showing
+ render(
+ ,
+ );
+
+ const angleIndicators = screen.getAllByText(/°/);
+
+ // Assert
+ expect(angleIndicators).toHaveLength(concavePolygonClockwise.length);
+ // Checking angles in render order
+ expect(angleIndicators[0]).toHaveTextContent("45°");
+ expect(angleIndicators[1]).toHaveTextContent("135°");
+ expect(angleIndicators[2]).toHaveTextContent("90°");
+ expect(angleIndicators[3]).toHaveTextContent("135°");
+ expect(angleIndicators[4]).toHaveTextContent("45°");
+ // Concave vertex, greater than 180 degrees
+ expect(angleIndicators[5]).toHaveTextContent("270°");
+ });
+
+ it("should show correct angles for concave polygons when the points are counter-clockwise", () => {
+ // Arrange
+
+ // Act - render with angles showing
+ render(
+ ,
+ );
+
+ const angleIndicators = screen.getAllByText(/°/);
+
+ // Assert
+ expect(angleIndicators).toHaveLength(concavePolygonClockwise.length);
+ // Concave vertex, greater than 180 degrees
+ expect(angleIndicators[0]).toHaveTextContent("270°");
+ expect(angleIndicators[1]).toHaveTextContent("45°");
+ expect(angleIndicators[2]).toHaveTextContent("135°");
+ expect(angleIndicators[3]).toHaveTextContent("90°");
+ expect(angleIndicators[4]).toHaveTextContent("135°");
+ expect(angleIndicators[5]).toHaveTextContent("45°");
+ });
+});
diff --git a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-polygon.tsx b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-polygon.tsx
index eef5a0cdb4b..caaa0c60142 100644
--- a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-polygon.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-polygon.tsx
@@ -1,3 +1,4 @@
+import {geometry} from "@khanacademy/kmath";
import {
lockedFigureColors,
lockedFigureFillStyles,
@@ -6,14 +7,25 @@ import {semanticColor} from "@khanacademy/wonder-blocks-tokens";
import {Point, Polygon} from "mafs";
import * as React from "react";
+import {PolygonAngle} from "../graphs/components/angle-indicators";
import {X, Y} from "../math";
import {strokeWeights} from "./utils";
import type {LockedPolygonType} from "@khanacademy/perseus-core";
+const {clockwise} = geometry;
+
const LockedPolygon = (props: LockedPolygonType) => {
- const {points, color, showVertices, fillStyle, strokeStyle, weight} = props;
+ const {
+ points,
+ color,
+ showVertices,
+ showAngles,
+ fillStyle,
+ strokeStyle,
+ weight,
+ } = props;
const hasAria = !!props.ariaLabel;
@@ -51,6 +63,24 @@ const LockedPolygon = (props: LockedPolygonType) => {
color={lockedFigureColors[color]}
/>
))}
+ {points.map((point, i) => {
+ const pt1 = points.at(i - 1);
+ const pt2 = points[(i + 1) % points.length];
+ // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
+ if (!pt1 || !pt2) {
+ return null;
+ }
+ return (
+
+ );
+ })}
);
};