diff --git a/.changeset/violet-worms-invite.md b/.changeset/violet-worms-invite.md
new file mode 100644
index 0000000000..bf762f8c97
--- /dev/null
+++ b/.changeset/violet-worms-invite.md
@@ -0,0 +1,6 @@
+---
+"@khanacademy/perseus": minor
+"@khanacademy/perseus-editor": minor
+---
+
+[Locked labels] View locked labels in an Interactive Graph
diff --git a/packages/perseus-editor/src/__stories__/flags-for-api-options.ts b/packages/perseus-editor/src/__stories__/flags-for-api-options.ts
index 479768047a..65e0285a57 100644
--- a/packages/perseus-editor/src/__stories__/flags-for-api-options.ts
+++ b/packages/perseus-editor/src/__stories__/flags-for-api-options.ts
@@ -15,6 +15,7 @@ export const flags = {
// Locked figures flags
"interactive-graph-locked-features-m2b": true,
+ "interactive-graph-locked-features-labels": true,
// Start coords UI flags
// TODO(LEMS-2228): Remove flags once this is fully released
diff --git a/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx b/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx
index f672376960..9ec83c3882 100644
--- a/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx
+++ b/packages/perseus-editor/src/__stories__/interactive-graph-editor.stories.tsx
@@ -135,7 +135,7 @@ export const MafsWithLockedFiguresCurrent = (): React.ReactElement => {
flags: {
mafs: {
...flags.mafs,
- "interactive-graph-locked-features-m2b": false,
+ "interactive-graph-locked-features-labels": false,
},
},
}}
@@ -152,13 +152,13 @@ MafsWithLockedFiguresCurrent.parameters = {
},
};
-export const MafsWithLockedFiguresM2bFlag = (): React.ReactElement => {
+export const MafsWithLockedLabelsFlag = (): React.ReactElement => {
return (
);
};
-MafsWithLockedFiguresM2bFlag.parameters = {
+MafsWithLockedLabelsFlag.parameters = {
chromatic: {
// Disabling because this isn't visually testing anything on the
// initial load of the editor page.
diff --git a/packages/perseus-editor/src/components/__tests__/util.test.ts b/packages/perseus-editor/src/components/__tests__/util.test.ts
index 90d54044f7..ff293c6b6a 100644
--- a/packages/perseus-editor/src/components/__tests__/util.test.ts
+++ b/packages/perseus-editor/src/components/__tests__/util.test.ts
@@ -98,6 +98,17 @@ describe("getDefaultFigureForType", () => {
directionalAxis: "x",
});
});
+
+ test("should return a 'label' with default values", () => {
+ const figure = getDefaultFigureForType("label");
+ expect(figure).toEqual({
+ type: "label",
+ coord: [0, 0],
+ text: "",
+ color: "grayH",
+ size: "medium",
+ });
+ });
});
describe("degreeToRadian", () => {
diff --git a/packages/perseus-editor/src/components/graph-locked-figures/locked-figures-section.tsx b/packages/perseus-editor/src/components/graph-locked-figures/locked-figures-section.tsx
index 2e48b1dcdd..6d1bdcb546 100644
--- a/packages/perseus-editor/src/components/graph-locked-figures/locked-figures-section.tsx
+++ b/packages/perseus-editor/src/components/graph-locked-figures/locked-figures-section.tsx
@@ -159,6 +159,13 @@ const LockedFiguresSection = (props: Props) => {
{isExpanded && (
{figures?.map((figure, index) => {
+ if (figure.type === "label") {
+ // TODO(LEMS-1795): Add locked label settings.
+ // Remove this block once label locked figure
+ // settings are implemented.
+ return;
+ }
+
return (
(
/>
);
+export const LockedLabel = (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 6aba5372bc..96bd5e0b44 100644
--- a/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts
+++ b/packages/perseus/src/widgets/__testdata__/interactive-graph.testdata.ts
@@ -804,6 +804,22 @@ export const segmentWithLockedFunction = (
.build();
};
+export const segmentWithLockedLabels: PerseusRenderer =
+ interactiveGraphQuestionBuilder()
+ .addLockedLabel("small \\frac{1}{2}", [-6, 2], {
+ color: "pink",
+ size: "small",
+ })
+ .addLockedLabel("medium E_0 = mc^2", [1, 2], {
+ color: "blue",
+ size: "medium",
+ })
+ .addLockedLabel("large \\sqrt{2a}", [-3, -2], {
+ color: "green",
+ size: "large",
+ })
+ .build();
+
export const segmentWithLockedFigures: PerseusRenderer =
interactiveGraphQuestionBuilder()
.addLockedPointAt(-7, -7)
@@ -822,6 +838,7 @@ export const segmentWithLockedFigures: PerseusRenderer =
.addLockedFunction("sin(x)", {
color: "red",
})
+ .addLockedLabel("\\sqrt{\\frac{1}{2}}", [6, -5])
.build();
export const quadraticQuestion: PerseusRenderer =
diff --git a/packages/perseus/src/widgets/__tests__/interactive-graph.test.tsx b/packages/perseus/src/widgets/__tests__/interactive-graph.test.tsx
index aa40acc71e..53a861a01a 100644
--- a/packages/perseus/src/widgets/__tests__/interactive-graph.test.tsx
+++ b/packages/perseus/src/widgets/__tests__/interactive-graph.test.tsx
@@ -35,6 +35,7 @@ import {
segmentWithLockedEllipses,
segmentWithLockedEllipseWhite,
segmentWithLockedFunction,
+ segmentWithLockedLabels,
segmentWithLockedLineQuestion,
segmentWithLockedPointsQuestion,
segmentWithLockedPointsWithColorQuestion,
@@ -861,4 +862,48 @@ describe("locked layer", () => {
expect(PlotOfXMock).toHaveBeenCalledTimes(0);
expect(PlotOfYMock).toHaveBeenCalledTimes(1);
});
+
+ it("should render locked labels", async () => {
+ // Arrange
+ const {container} = renderQuestion(segmentWithLockedLabels, {
+ flags: {
+ mafs: {
+ segment: true,
+ "interactive-graph-locked-features-labels": true,
+ },
+ },
+ });
+
+ // Act
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
+ const labels = container.querySelectorAll(".locked-label");
+
+ // Assert
+ expect(labels).toHaveLength(3);
+
+ // content
+ expect(labels[0]).toHaveTextContent("small \\frac{1}{2}");
+ expect(labels[1]).toHaveTextContent("medium E_0 = mc^2");
+ expect(labels[2]).toHaveTextContent("large \\sqrt{2a}");
+
+ // styles
+ expect(labels[0]).toHaveStyle({
+ color: lockedFigureColors["pink"],
+ fontSize: "14px", // small
+ left: "80px",
+ top: "160px",
+ });
+ expect(labels[1]).toHaveStyle({
+ color: lockedFigureColors["blue"],
+ fontSize: "16px", // medium
+ left: "220px",
+ top: "160px",
+ });
+ expect(labels[2]).toHaveStyle({
+ color: lockedFigureColors["green"],
+ fontSize: "20px", // large
+ left: "140px",
+ top: "240px",
+ });
+ });
});
diff --git a/packages/perseus/src/widgets/interactive-graph.tsx b/packages/perseus/src/widgets/interactive-graph.tsx
index 02dbf22a45..8dbae8bd1c 100644
--- a/packages/perseus/src/widgets/interactive-graph.tsx
+++ b/packages/perseus/src/widgets/interactive-graph.tsx
@@ -1821,6 +1821,11 @@ class InteractiveGraph extends React.Component {
return (
;
+};
+
+export default function GraphLockedLabelsLayer(props: Props) {
+ const {lockedFigures} = props;
+
+ return lockedFigures.map((figure, i) => {
+ if (figure.type === "label") {
+ return ;
+ }
+
+ // TODO(LEMS-2271): Add support for labels within
+ // other locked figure types
+ return null;
+ });
+}
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 a6959107ba..2f900a6f81 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graph-locked-layer.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graph-locked-layer.tsx
@@ -59,6 +59,12 @@ const GraphLockedLayer = (props: Props) => {
{...figure}
/>
);
+ case "label":
+ // This is rendered outside the SVG element, since
+ // TeX cannot be rendered inside an SVG.
+ // See graph-locked-labels-layer.tsx for
+ // the component that renders these.
+ return null;
default:
/**
* Devlopment-time future-proofing: This should
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 63f90bc73a..d1190240da 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
@@ -961,4 +961,41 @@ describe("InteractiveGraphQuestionBuilder", () => {
},
]);
});
+
+ it("adds a locked label", () => {
+ const question: PerseusRenderer = interactiveGraphQuestionBuilder()
+ .addLockedLabel("the text", [1, 2])
+ .build();
+ const graph = question.widgets["interactive-graph 1"];
+
+ expect(graph.options.lockedFigures).toEqual([
+ {
+ type: "label",
+ text: "the text",
+ coord: [1, 2],
+ color: "grayH",
+ size: "medium",
+ },
+ ]);
+ });
+
+ it("adds a locked label with options", () => {
+ const question: PerseusRenderer = interactiveGraphQuestionBuilder()
+ .addLockedLabel("some other text", [15, 2], {
+ color: "green",
+ size: "large",
+ })
+ .build();
+ const graph = question.widgets["interactive-graph 1"];
+
+ expect(graph.options.lockedFigures).toEqual([
+ {
+ type: "label",
+ text: "some other text",
+ coord: [15, 2],
+ color: "green",
+ size: "large",
+ },
+ ]);
+ });
});
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 0e0e70bc8a..e87624e54d 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
@@ -6,6 +6,7 @@ import type {
LockedFigureColor,
LockedFigureFillType,
LockedFunctionType,
+ LockedLabelType,
LockedLineType,
LockedPointType,
LockedPolygonType,
@@ -383,6 +384,27 @@ class InteractiveGraphQuestionBuilder {
return this;
}
+ addLockedLabel(
+ text: string,
+ coord: Coord,
+ options?: {
+ color?: LockedFigureColor;
+ size?: "small" | "medium" | "large";
+ },
+ ) {
+ const lockedLabel: LockedLabelType = {
+ type: "label",
+ coord,
+ text,
+ color: "grayH",
+ size: "medium",
+ ...options,
+ };
+
+ this.addLockedFigure(lockedLabel);
+ return this;
+ }
+
private createLockedPoint(
x: number,
y: number,
diff --git a/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-label.tsx b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-label.tsx
new file mode 100644
index 0000000000..93ca566b41
--- /dev/null
+++ b/packages/perseus/src/widgets/interactive-graphs/locked-figures/locked-label.tsx
@@ -0,0 +1,31 @@
+import {font} from "@khanacademy/wonder-blocks-tokens";
+import * as React from "react";
+
+import {getDependencies} from "../../../dependencies";
+import {lockedFigureColors, type LockedLabelType} from "../../../perseus-types";
+import {pointToPixel} from "../graphs/use-transform";
+import useGraphConfig from "../reducer/use-graph-config";
+
+export default function LockedLabel(props: LockedLabelType) {
+ const {coord, text, color, size} = props;
+
+ const [x, y] = pointToPixel(coord, useGraphConfig());
+
+ const {TeX} = getDependencies();
+
+ // Move this all outside the SVG element
+ return (
+
+ {text}
+
+ );
+}
diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx
index 9420b0835d..0f9d1e987a 100644
--- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx
@@ -8,6 +8,7 @@ import AxisLabels from "./backgrounds/axis-labels";
import {AxisTickLabels} from "./backgrounds/axis-tick-labels";
import {Grid} from "./backgrounds/grid";
import {LegacyGrid} from "./backgrounds/legacy-grid";
+import GraphLockedLabelsLayer from "./graph-locked-labels-layer";
import GraphLockedLayer from "./graph-locked-layer";
import {
LinearGraph,
@@ -35,6 +36,7 @@ import "mafs/core.css";
import "./mafs-styles.css";
export type MafsGraphProps = {
+ showLabelsFlag?: boolean;
box: [number, number];
backgroundImage?: InteractiveGraphProps["backgroundImage"];
lockedFigures?: InteractiveGraphProps["lockedFigures"];
@@ -90,6 +92,12 @@ export const MafsGraph = (props: MafsGraphProps) => {
box={props.box}
backgroundImage={props.backgroundImage}
/>
+ {/* Locked labels layer */}
+ {props.showLabelsFlag && props.lockedFigures && (
+
+ )}
{
containerSizeClass={props.containerSizeClass}
markings={props.markings}
/>
- {/* Locked layer */}
+ {/* Locked figures layer */}
{props.lockedFigures && (