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 && (