Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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/large-crews-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@khanacademy/perseus": minor
"@khanacademy/perseus-core": minor
"@khanacademy/perseus-editor": minor
---

Render visible point labels on interactive graphs when the`perseus-enable-point-label-field` flag is on and the graph's`showPointLabels` field is true. Labels render via TeX in an HTML overlayso authors can include math (`$A$`, `$\theta$`, …) and stay outside theplotted region as a point is dragged toward an edge. Covers point,circle, angle, polygon, sinusoid, linear, linear-system, ray, andsegment in this release; remaining graph types follow in a per-graphseries. Existing content that sets `pointLabels` for screen-readerpurposes is unaffected — visible rendering requires both the flag and`showPointLabels: true`.
1 change: 1 addition & 0 deletions packages/perseus-core/src/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
const PerseusFeatureFlags = [
"input-number-to-numeric-input", // TODO(LEMS-4085): clean up feature flag
"perseus-enable-point-label-field", // TODO(AITQ-385): clean up feature flag
] as const;

export default PerseusFeatureFlags;
Expand Down
1 change: 1 addition & 0 deletions packages/perseus-editor/src/testing/feature-flags-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const DEFAULT_FEATURE_FLAGS = {
"interactive-graph-vector": false,
"interactive-graph-not-scored": false,
"input-number-to-numeric-input": false,
"perseus-enable-point-label-field": false,
Comment thread
EmiliaPalaghita marked this conversation as resolved.
Outdated
// ...add new flags here
};

Expand Down
1 change: 1 addition & 0 deletions packages/perseus/src/testing/feature-flags-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const DEFAULT_FEATURE_FLAGS = {
"interactive-graph-vector": false,
"interactive-graph-not-scored": false,
"input-number-to-numeric-input": false,
"perseus-enable-point-label-field": false,
Comment thread
EmiliaPalaghita marked this conversation as resolved.
Outdated
// ...add new flags here
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {
pointQuestion,
pointWithCustomLabelQuestion,
pointWithDefaultLabelQuestion,
pointWithVisibleLabelsQuestion,
polygonQuestion,
polygonWithCustomLabelsQuestion,
polygonWithVisibleLabelsQuestion,
rayQuestion,
rayWithCustomLabelsQuestion,
segmentQuestion,
Expand Down Expand Up @@ -185,6 +187,26 @@ export const PointWithDefaultLabel: Story = {
},
};

/**
* A point graph with `showPointLabels: true` and author-supplied
* `pointLabels: ["P", "Q", "R"]`. The same per-index string drives both
* the visible on-canvas label and the screen-reader announcement, so the
* two stay in sync. `showPointLabels` always requires `pointLabels` — the
* interactive-graph-widget-error lint rule blocks the combination
* `showPointLabels: true` without `pointLabels` at authoring time, so
* non-Latin locales never see auto-generated Latin letters.
*/
export const PointWithVisibleLabels: Story = {
Comment thread
EmiliaPalaghita marked this conversation as resolved.
Outdated
globals: {
featureFlags: ["perseus-enable-point-label-field"],
},
args: {
item: generateTestPerseusItem({
question: pointWithVisibleLabelsQuestion,
}),
},
};

export const Polygon: Story = {
args: {
item: generateTestPerseusItem({question: polygonQuestion}),
Expand All @@ -206,6 +228,25 @@ export const PolygonWithCustomLabels: Story = {
},
};

/**
* A polygon graph with `showPointLabels: true` and author-supplied
* `pointLabels: ["A", "B", "C", "D"]`. Each vertex carries its assigned
* letter as both the visible on-canvas label and the screen-reader
* announcement ("Point A / B / C / D at …"). Demonstrates that the
* `showPointLabels` opt-in works for non-`point` graph types via the same
* per-index plumbing.
*/
export const PolygonWithVisibleLabels: Story = {
globals: {
featureFlags: ["perseus-enable-point-label-field"],
},
args: {
item: generateTestPerseusItem({
question: polygonWithVisibleLabelsQuestion,
}),
},
};

export const UnlimitedPolygon: Story = {
args: {
item: generateTestPerseusItem({question: unlimitedPolygonQuestion}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as React from "react";
import {usePerseusI18n} from "../../../components/i18n-context";
import {X, Y} from "../math";
import {findIntersectionOfRays} from "../math/geometry";
import {getEffectivePointLabels} from "../point-labels";
import {actions} from "../reducer/interactive-graph-action";
import useGraphConfig from "../reducer/use-graph-config";

Expand Down Expand Up @@ -54,6 +55,7 @@ function AngleGraph(props: AngleGraphProps) {
const {
coords,
pointLabels,
showPointLabels,
showAngles,
range,
allowReflexAngles,
Expand All @@ -63,7 +65,12 @@ function AngleGraph(props: AngleGraphProps) {
// [2]=starting side. The MovablePoints below are rendered in a
// different order (vertex first), so each call site indexes pointLabels
// by the coords slot it is bound to.
const buildLabel = usePointAriaLabel(pointLabels);
const effectiveLabels = getEffectivePointLabels(
Comment thread
EmiliaPalaghita marked this conversation as resolved.
showPointLabels,
pointLabels,
coords.length,
);
const buildLabel = usePointAriaLabel(effectiveLabels);

// Break the coords into the two end points and the center point
const endPoints: [vec.Vector2, vec.Vector2] = [coords[0], coords[2]];
Expand Down
14 changes: 12 additions & 2 deletions packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {useRef} from "react";

import {usePerseusI18n} from "../../../components/i18n-context";
import {snap, X, Y} from "../math";
import {getEffectivePointLabels} from "../point-labels";
import {actions} from "../reducer/interactive-graph-action";
import {getRadius} from "../reducer/interactive-graph-state";
import useGraphConfig from "../reducer/use-graph-config";
Expand Down Expand Up @@ -48,10 +49,19 @@ type CircleGraphProps = MafsGraphProps<CircleGraphState>;
// Exported for testing
export function CircleGraph(props: CircleGraphProps) {
const {dispatch, graphState} = props;
const {center, pointLabels, radiusPoint, snapStep} = graphState;
const {center, pointLabels, showPointLabels, radiusPoint, snapStep} =
graphState;

const {strings, locale} = usePerseusI18n();
const buildLabel = usePointAriaLabel(pointLabels);
// Circle only has one labelable point (the radius point at index 0); the
// center is a MovableCircle, not a MovablePoint, and is intentionally not
// overridden — see MovablePoint's ariaLabel below.
const effectiveLabels = getEffectivePointLabels(
showPointLabels,
pointLabels,
1,
);
const buildLabel = usePointAriaLabel(effectiveLabels);

const radius = getRadius(graphState);
const id = React.useId();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {geometry} from "@khanacademy/kmath";
import * as React from "react";

import {usePerseusI18n} from "../../../components/i18n-context";
import {getEffectivePointLabels} from "../point-labels";
import {actions} from "../reducer/interactive-graph-action";

import {usePointAriaLabel} from "./components/build-point-aria-label";
Expand Down Expand Up @@ -37,12 +38,19 @@ type LinearSystemGraphProps = MafsGraphProps<LinearSystemGraphState>;

const LinearSystemGraph = (props: LinearSystemGraphProps) => {
const {dispatch} = props;
const {coords: lines, pointLabels} = props.graphState;
const {coords: lines, pointLabels, showPointLabels} = props.graphState;

const {strings, locale} = usePerseusI18n();
const id = React.useId();
const intersectionId = `${id}-intersection`;
const buildLabel = usePointAriaLabel(pointLabels);
// Each line has 2 endpoints; pointLabels is flat across both lines:
// [line0Start, line0End, line1Start, line1End].
const effectiveLabels = getEffectivePointLabels(
showPointLabels,
pointLabels,
lines.length * 2,
);
const buildLabel = usePointAriaLabel(effectiveLabels);

const intersectionPoint = geometry.getLineIntersection(lines[0], lines[1]);
const intersectionDescription = intersectionPoint
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from "react";

import {usePerseusI18n} from "../../../components/i18n-context";
import {getEffectivePointLabels} from "../point-labels";
import {actions} from "../reducer/interactive-graph-action";

import {usePointAriaLabel} from "./components/build-point-aria-label";
Expand Down Expand Up @@ -33,10 +34,15 @@ type LinearGraphProps = MafsGraphProps<LinearGraphState>;

const LinearGraph = (props: LinearGraphProps, key: number) => {
const {dispatch} = props;
const {coords: line, pointLabels} = props.graphState;
const {coords: line, pointLabels, showPointLabels} = props.graphState;

const {strings, locale} = usePerseusI18n();
const buildLabel = usePointAriaLabel(pointLabels);
const effectiveLabels = getEffectivePointLabels(
showPointLabels,
pointLabels,
2,
);
const buildLabel = usePointAriaLabel(effectiveLabels);
const id = React.useId();
const pointsDescriptionId = id + "-points";
const interceptDescriptionId = id + "-intercept";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,56 @@ describe("getPointGraphDescription", () => {
);
});

it("falls back to the numeric 'Point N' default when showPointLabels is true but pointLabels is omitted (lint rule blocks this combination at authoring time)", () => {
// Defense in depth: even if a bad item somehow lands in the database
// with showPointLabels:true and no pointLabels (the
// interactive-graph-widget-error lint rule blocks the combination
// at save time), the renderer must NOT auto-generate Latin letters
// — that would leak into non-Latin-alphabet locales.
const state: PointGraphState = {
...baseState,
showPointLabels: true,
coords: [
[0, 0],
[1, 1],
[2, 2],
],
};
expect(getPointGraphDescription(state, mockPerseusI18nContext)).toBe(
"Interactive elements: Point 1 at 0 comma 0. Point 2 at 1 comma 1. Point 3 at 2 comma 2.",
);
});

it("uses author pointLabels when showPointLabels is true and both are present", () => {
const state: PointGraphState = {
...baseState,
showPointLabels: true,
coords: [
[0, 0],
[1, 1],
],
pointLabels: ["P", "Q"],
};
expect(getPointGraphDescription(state, mockPerseusI18nContext)).toBe(
"Interactive elements: Point P at 0 comma 0. Point Q at 1 comma 1.",
);
});

it("falls back to the numeric 'Point N' default for points without a pointLabel entry when pointLabels is partial", () => {
const state: PointGraphState = {
...baseState,
showPointLabels: true,
coords: [
[0, 0],
[1, 1],
],
pointLabels: ["P"],
};
expect(getPointGraphDescription(state, mockPerseusI18nContext)).toBe(
"Interactive elements: Point P at 0 comma 0. Point 2 at 1 comma 1.",
);
});

it(`encodes "only the second point labeled" as ["", "T"] and announces "Point 1 ... Point T ..."`, () => {
const state: PointGraphState = {
...baseState,
Expand Down
29 changes: 23 additions & 6 deletions packages/perseus/src/widgets/interactive-graphs/graphs/point.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {useTimeout} from "@khanacademy/wonder-blocks-timing";
import * as React from "react";

import {getEffectivePointLabels} from "../point-labels";
import {actions} from "../reducer/interactive-graph-action";
import useGraphConfig from "../reducer/use-graph-config";
import {getCSSZoomFactor} from "../utils";
Expand Down Expand Up @@ -83,12 +84,17 @@ function PointGraph(props: Props) {

function LimitedPointGraph(statefulProps: StatefulProps) {
const {dispatch} = statefulProps;
const {pointLabels} = statefulProps.graphState;
const buildLabel = usePointAriaLabel(pointLabels);
const {coords, pointLabels, showPointLabels} = statefulProps.graphState;
const effectiveLabels = getEffectivePointLabels(
showPointLabels,
pointLabels,
coords.length,
);
const buildLabel = usePointAriaLabel(effectiveLabels);

return (
<>
{statefulProps.graphState.coords.map((point, i) => (
{coords.map((point, i) => (
<MovablePoint
key={i}
point={point}
Expand All @@ -111,8 +117,13 @@ function LimitedPointGraph(statefulProps: StatefulProps) {

function UnlimitedPointGraph(statefulProps: StatefulProps) {
const {dispatch, graphConfig, pointsRef, top, left} = statefulProps;
const {coords, pointLabels} = statefulProps.graphState;
const buildLabel = usePointAriaLabel(pointLabels);
const {coords, pointLabels, showPointLabels} = statefulProps.graphState;
const effectiveLabels = getEffectivePointLabels(
showPointLabels,
pointLabels,
coords.length,
);
const buildLabel = usePointAriaLabel(effectiveLabels);

// When users drag a point on iOS Safari, the browser fires a click event after the mouseup
// at the original click location, which would add an unwanted new point. We track drag
Expand Down Expand Up @@ -212,10 +223,16 @@ export function getPointGraphDescription(
return strings.srNoInteractiveElements;
}

const effectiveLabels = getEffectivePointLabels(
state.showPointLabels,
state.pointLabels,
state.coords.length,
);

const pointDescriptions = state.coords.map(
(point, index) =>
buildPointAriaLabel(
state.pointLabels,
effectiveLabels,
index,
point,
strings,
Expand Down
28 changes: 23 additions & 5 deletions packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "../../../components/i18n-context";
import {snap} from "../math";
import {isInBound} from "../math/box";
import {getEffectivePointLabels} from "../point-labels";
import {actions} from "../reducer/interactive-graph-action";
import {
calculateAngleSnap,
Expand Down Expand Up @@ -185,14 +186,20 @@ const LimitedPolygonGraph = (statefulProps: StatefulProps) => {
const {
showAngles,
showSides,
showPointLabels,
range,
snapTo = "grid",
snapStep,
pointLabels,
} = statefulProps.graphState;
const {disableKeyboardInteraction, interactiveColor} = graphConfig;
const {strings, locale} = usePerseusI18n();
const buildLabel = usePointAriaLabel(pointLabels);
const effectiveLabels = getEffectivePointLabels(
showPointLabels,
pointLabels,
points.length,
);
const buildLabel = usePointAriaLabel(effectiveLabels);
const id = React.useId();

const lines = getLines(points);
Expand Down Expand Up @@ -412,9 +419,15 @@ const LimitedPolygonGraph = (statefulProps: StatefulProps) => {

const UnlimitedPolygonGraph = (statefulProps: StatefulProps) => {
const {dispatch, graphConfig, left, top, pointsRef, points} = statefulProps;
const {coords, closedPolygon, pointLabels} = statefulProps.graphState;
const {coords, closedPolygon, pointLabels, showPointLabels} =
statefulProps.graphState;
const {strings, locale} = usePerseusI18n();
const buildLabel = usePointAriaLabel(pointLabels);
const effectiveLabels = getEffectivePointLabels(
showPointLabels,
pointLabels,
coords.length,
);
const buildLabel = usePointAriaLabel(effectiveLabels);
const {interactiveColor} = useGraphConfig();

// When users drag a point on iOS Safari, the browser fires a click event after the mouseup
Expand Down Expand Up @@ -666,9 +679,14 @@ function describePolygonGraph(
markings: InteractiveGraphProps["markings"],
): PolygonGraphDescriptionStrings {
const {strings, locale} = i18n;
const {coords, pointLabels} = state;
const {coords, pointLabels, showPointLabels} = state;
const effectiveLabels = getEffectivePointLabels(
showPointLabels,
pointLabels,
coords.length,
);
const buildLabel = (index: number, point: vec.Vector2) =>
buildPointAriaLabel(pointLabels, index, point, strings, locale);
buildPointAriaLabel(effectiveLabels, index, point, strings, locale);
const isCoordinatePlane = markings === "axes" || markings === "graph";
const hasOnePoint = coords.length === 1;

Expand Down
Loading
Loading