Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Interactive Graph] Open and Closing logic for unlimited polygon. #1852

Merged
merged 33 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
aa64c95
Got a basic open polygon experience working.
catandthemachines Oct 30, 2024
e5876f0
Adding more comments.
catandthemachines Oct 30, 2024
e68261e
Simplifying logic.
catandthemachines Oct 30, 2024
bca7e2a
Adding attempts to simply logic between the two components
catandthemachines Oct 30, 2024
184993d
Adding strings and buttons to add and removing points.
catandthemachines Oct 30, 2024
2ee6162
Trying to improve the point graph
catandthemachines Oct 30, 2024
f24b118
Adding disabled funtionality.
catandthemachines Oct 30, 2024
55b4415
Adding unit testing.
catandthemachines Oct 30, 2024
7731db1
Adding new tests for opening and closing polygon.
catandthemachines Oct 30, 2024
e6c447e
Adding more tests.
catandthemachines Oct 30, 2024
1ba3482
Adding logic to close shape when clicking on the first point.
catandthemachines Oct 31, 2024
f0418e6
Updating description.
catandthemachines Oct 31, 2024
5aacf23
Adding additional key event.
catandthemachines Oct 31, 2024
fc6c767
Adding for posterity.
catandthemachines Nov 1, 2024
c95b58b
Merge branch 'main' into catjohnson/open-unlimited-poly
catandthemachines Nov 1, 2024
b0d53b8
Added comments based on chat w/ Caitlyn.
catandthemachines Nov 1, 2024
a29f8b8
Merge branch 'main' into catjohnson/open-unlimited-poly
catandthemachines Nov 12, 2024
82ead83
Adding work to re-align buttons. Still more to add here.
catandthemachines Nov 12, 2024
78e6542
Merge branch 'main' into catjohnson/open-unlimited-poly
catandthemachines Nov 13, 2024
48699f0
Adding in disabled logic.
catandthemachines Nov 13, 2024
4d03b20
Add graph lines to dev example.
catandthemachines Nov 14, 2024
cd416e2
adding changeset.
catandthemachines Nov 14, 2024
df98397
Removing a few TODOs, and fixing a few tests.
catandthemachines Nov 14, 2024
f5a2116
Adding more robust testing. <3
catandthemachines Nov 14, 2024
8f13391
Cleaning up polygon file.
catandthemachines Nov 14, 2024
44bcf54
Removing unneeded comment.
catandthemachines Nov 14, 2024
186b768
Adding storybook example for unlimited polygon with mafs. <3
catandthemachines Nov 14, 2024
ff76028
updating bulk tests.
catandthemachines Nov 15, 2024
762cc03
remove unnecessary props.
catandthemachines Nov 15, 2024
8e860f5
Merge branch 'main' into catjohnson/open-unlimited-poly
catandthemachines Nov 15, 2024
6c4a3b1
Moving new line to proper location.
catandthemachines Nov 15, 2024
95470bb
Adding property to test.
catandthemachines Nov 15, 2024
d0842b3
removing comment.
catandthemachines Nov 20, 2024
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
5 changes: 5 additions & 0 deletions .changeset/twenty-baboons-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": patch
---

Adding open and closing behavior to unlimited polygon graph type.
6 changes: 6 additions & 0 deletions packages/perseus/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ export type PerseusStrings = {
addPoint: string;
removePoint: string;
graphKeyboardPrompt: string;
closePolygon: string;
openPolygon: string;
srPointAtCoordinates: ({x, y}: {x: string; y: string}) => string;
srInteractiveElements: ({elements}: {elements: string}) => string;
srNoInteractiveElements: string;
Expand Down Expand Up @@ -311,6 +313,8 @@ export const strings: {
addPoint: "Add Point",
removePoint: "Remove Point",
graphKeyboardPrompt: "Press Shift + Enter to interact with the graph",
closePolygon: "Close shape",
openPolygon: "Re-open shape",
srPointAtCoordinates: {
context: "Screenreader-accessible description of a point on a graph",
message: "Point at %(x)s comma %(y)s",
Expand Down Expand Up @@ -476,6 +480,8 @@ export const mockStrings: PerseusStrings = {
addPoint: "Add Point",
removePoint: "Remove Point",
graphKeyboardPrompt: "Press Shift + Enter to interact with the graph",
closePolygon: "Close shape",
openPolygon: "Re-open shape",
srPointAtCoordinates: ({x, y}) => `Point at ${x} comma ${y}`,
srInteractiveElements: ({elements}) => `Interactive elements: ${elements}`,
srNoInteractiveElements: "No interactive elements",
Expand Down
226 changes: 66 additions & 160 deletions packages/perseus/src/widgets/interactive-graphs/graphs/polygon.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Polygon, vec} from "mafs";
import {Polygon, Polyline, vec} from "mafs";
import * as React from "react";

import {snap} from "../math";
Expand Down Expand Up @@ -169,32 +169,14 @@ const LimitedPolygonGraph = (props: Props) => {
);
};

// TODO(catjohnson): reduce redundancy between LimitedPolygonGraph and UnlimitedPolygonGraph
// both components are vary similar, however more implementation is needed to be added before
// it is clear what can and can't be shared between components.
const UnlimitedPolygonGraph = (props: Props) => {
const [hovered, setHovered] = React.useState(false);
// This is more so required for the re-rendering that occurs when state
// updates; specifically with regard to line weighting and polygon focus.
const [focusVisible, setFocusVisible] = React.useState(false);

const {dispatch} = props;
const {
coords,
showAngles,
showSides,
range,
snapStep,
snapTo = "grid",
} = props.graphState;
const {coords, closedPolygon} = props.graphState;

const graphConfig = useGraphConfig();

// TODO(catjohnson): Explore abstracting this code as it is similar to point.tsx
// and hopefully we can cut down ont the unlimited graph redundancy.
const {
range: [x, y],
disableKeyboardInteraction,
graphDimensionsInPixels,
} = graphConfig;

Expand All @@ -207,160 +189,84 @@ const UnlimitedPolygonGraph = (props: Props) => {
// TODO(benchristel): can the default set of points be removed here? I don't
// think coords can be null.
const points = coords ?? [[0, 0]];
const polygonRef = React.useRef<SVGPolygonElement>(null);
const dragReferencePoint = points[0];
const constrain = ["angles", "sides"].includes(snapTo)
? (p) => p
: (p) => snap(snapStep, p);
const {dragging} = useDraggable({
gestureTarget: polygonRef,
point: dragReferencePoint,
onMove: (newPoint) => {
const delta = vec.sub(newPoint, dragReferencePoint);
dispatch(actions.polygon.moveAll(delta));
},
constrainKeyboardMovement: constrain,
});

const lines = getLines(points);
React.useEffect(() => {
const focusedIndex = props.graphState.focusedPointIndex;
if (focusedIndex != null) {
pointRef.current[focusedIndex]?.focus();
}
}, [props.graphState.focusedPointIndex, pointRef]);

return (
<>
{/* This rect is here to grab clicks so that new points can be added */}
{/* It's important because it stops mouse events from propogating
if (closedPolygon) {
const closedPolygonProps = {...props, numSides: coords.length};
return <LimitedPolygonGraph {...closedPolygonProps} />;
} else {
return (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small nit: the else isn't needed here since you return in your if statement

<>
{/* This rect is here to grab clicks so that new points can be added */}
{/* It's important because it stops mouse events from propogating
when dragging a points around */}
<rect
style={{
fill: "rgba(0,0,0,0)",
cursor: "crosshair",
}}
width={widthPx}
height={heightPx}
x={left}
y={top}
onClick={(event) => {
const elementRect =
event.currentTarget.getBoundingClientRect();
<rect
style={{
fill: "rgba(0,0,0,0)",
cursor: "crosshair",
}}
width={widthPx}
height={heightPx}
x={left}
y={top}
onClick={(event) => {
const elementRect =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can this be moved into a handler function?

    function handleOnClick(event) {
        const elementRect =
            event.currentTarget.getBoundingClientRect();

        const x = event.clientX - elementRect.x;
        const y = event.clientY - elementRect.y;

        const graphCoordinates = pixelsToVectors(
            [[x, y]],
            graphConfig,
        );
        dispatch(actions.polygon.addPoint(graphCoordinates[0]));
    }

then

    <rect
        style={{
            fill: "rgba(0,0,0,0)",
            cursor: "crosshair",
        }}
        width={widthPx}
        height={heightPx}
        x={left}
        y={top}
        onClick={handleOnClick}
    />

event.currentTarget.getBoundingClientRect();

const x = event.clientX - elementRect.x;
const y = event.clientY - elementRect.y;
const x = event.clientX - elementRect.x;
const y = event.clientY - elementRect.y;

const graphCoordinates = pixelsToVectors(
[[x, y]],
graphConfig,
);
dispatch(actions.polygon.addPoint(graphCoordinates[0]));
}}
/>
{/**
* TODO(catjohnson): Will need to conditionally render then once a full polygon is created
* And handle when someone wants to remove the polygon connection.
*/}
<Polygon
points={[...points]}
color="var(--movable-line-stroke-color)"
svgPolygonProps={{
strokeWidth: focusVisible
? "var(--movable-line-stroke-weight-active)"
: "var(--movable-line-stroke-weight)",
style: {fill: "transparent"},
}}
/>
{props.graphState.coords.map((point, i) => {
const pt1 = points.at(i - 1);
const pt2 = points[(i + 1) % points.length];
if (!pt1 || !pt2) {
return null;
}
return (
<PolygonAngle
key={"angle-" + i}
centerPoint={point}
endPoints={[pt1, pt2]}
range={range}
polygonLines={lines}
showAngles={!!showAngles}
snapTo={snapTo}
/>
);
})}
{showSides &&
lines.map(([start, end], i) => {
const [x, y] = vec.midpoint(start, end);
const length = parseFloat(
vec
.dist(start, end)
.toFixed(snapTo === "sides" ? 0 : 1),
);
return (
<TextLabel key={"side-" + i} x={x} y={y}>
{!Number.isInteger(length) && "≈ "}
{length}
</TextLabel>
);
})}
{/**
* This transparent svg creates a nice big click/touch target,
* since the polygon itself can be made smaller than the spec.
*/}
{/**
* Will likely want to conditionally render then once a full polygon is created
* And handle when someone wants to remove the polygon connection?
*/}
<Polygon
points={[...points]}
color="transparent"
svgPolygonProps={{
ref: polygonRef,
tabIndex: disableKeyboardInteraction ? -1 : 0,
strokeWidth: TARGET_SIZE,
style: {
cursor: dragging ? "grabbing" : "grab",
fill: hovered ? "var(--mafs-blue)" : "transparent",
},
onMouseEnter: () => setHovered(true),
onMouseLeave: () => setHovered(false),
// Required to remove line weighting when user clicks away
// from the focused polygon
onKeyDownCapture: () => {
setFocusVisible(hasFocusVisible(polygonRef.current));
},
// Required for lines to darken on focus
onFocus: () =>
setFocusVisible(hasFocusVisible(polygonRef.current)),
// Required for line weighting to update on blur. Without this,
// the user has to hover over the shape for it to update
onBlur: () =>
setFocusVisible(hasFocusVisible(polygonRef.current)),
className: "movable-polygon",
}}
/>
{props.graphState.coords.map((point, i) => (
<MovablePoint
key={i}
point={point}
onMove={(destination) =>
dispatch(actions.pointGraph.movePoint(i, destination))
}
ref={(ref) => {
pointRef.current[i] = ref;
}}
onFocus={() => {
dispatch(actions.pointGraph.focusPoint(i));
const graphCoordinates = pixelsToVectors(
catandthemachines marked this conversation as resolved.
Show resolved Hide resolved
[[x, y]],
graphConfig,
);
dispatch(actions.polygon.addPoint(graphCoordinates[0]));
}}
onClick={() => {
dispatch(actions.pointGraph.clickPoint(i));
/>
<Polyline
points={[...points]}
color="var(--movable-line-stroke-color)"
svgPolylineProps={{
strokeWidth: "var(--movable-line-stroke-weight)",
style: {fill: "transparent"},
}}
/>
))}
</>
);
{props.graphState.coords.map((point, i) => (
<MovablePoint
key={i}
point={point}
onMove={(destination) =>
dispatch(actions.polygon.movePoint(i, destination))
}
ref={(ref) => {
pointRef.current[i] = ref;
}}
onFocus={() => {
dispatch(actions.polygon.focusPoint(i));
}}
onClick={() => {
// If the point being clicked is the first point and
// there's enough points to form a polygon (3 or more)
// Close the shape before setting focus.
if (
i === 0 &&
props.graphState.coords.length >= 3
) {
dispatch(actions.polygon.closePolygon());
}
dispatch(actions.polygon.clickPoint(i));
}}
/>
))}
</>
);
}
};

function getLines(points: readonly vec.Vector2[]): CollinearTuple[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
staticGraphQuestion,
staticGraphQuestionWithAnotherWidget,
segmentWithLockedLabels,
unlimitedPolygonQuestion,
} from "./interactive-graph.testdata";

import type {APIOptions} from "../../types";
Expand All @@ -37,6 +38,7 @@ const enableMafs: APIOptions = {
mafs: {
segment: true,
polygon: true,
"unlimited-polygon": true,
angle: true,
"interactive-graph-locked-features-labels": true,
},
Expand Down Expand Up @@ -80,6 +82,15 @@ export const PolygonWithMafs = (args: StoryArgs): React.ReactElement => (
/>
);

export const UnlimitedPolygonWithMafs = (
args: StoryArgs,
): React.ReactElement => (
<RendererWithDebugUI
apiOptions={{...enableMafs}}
question={unlimitedPolygonQuestion}
/>
);

export const PolygonWithMafsReadOnly = (
args: StoryArgs,
): React.ReactElement => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,25 @@ export const polygonQuestion: PerseusRenderer =
})
.build();

export const unlimitedPolygonQuestion: PerseusRenderer =
interactiveGraphQuestionBuilder()
.withContent(
"**Sides shown** Drag the vertices of the triangle below to draw a right triangle with side lengths $3$, $4$, and $5$. \n[[\u2603 interactive-graph 1]] \n",
)
.withGridStep(1, 1)
.withSnapStep(0.25, 0.25)
.withTickStep(1, 1)
.withXRange(-10, 10)
.withYRange(-10, 10)
.withPolygon("grid", {
match: "congruent",
numSides: "unlimited",
showSides: true,
showAngles: true,
coords: [],
})
.build();

export const polygonWithStartingCoordsQuestion: PerseusRenderer =
interactiveGraphQuestionBuilder()
.withPolygon("grid", {
Expand Down Expand Up @@ -284,6 +303,8 @@ export const polygonWithUnlimitedSidesQuestion: PerseusRenderer =
"**Example of unlimited polygon sides** \n[[\u2603 interactive-graph 1]] \n",
)
.withPolygon("grid", {
showAngles: true,
showSides: true,
numSides: "unlimited",
coords: [
[0, 0],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ describe("MafsGraph", () => {
snapStep: [2, 2],
snapTo: "grid",
coords: [[4, 5]],
closedPolygon: false,
};

const baseMafsGraphProps: MafsGraphProps = {
Expand Down Expand Up @@ -593,6 +594,7 @@ describe("MafsGraph", () => {
snapStep: [2, 2],
snapTo: "grid",
coords: [[9, 9]],
closedPolygon: false,
};

const baseMafsGraphProps: MafsGraphProps = {
Expand Down
Loading
Loading