diff --git a/.changeset/loud-goats-grow.md b/.changeset/loud-goats-grow.md
new file mode 100644
index 0000000000..91c159a70b
--- /dev/null
+++ b/.changeset/loud-goats-grow.md
@@ -0,0 +1,6 @@
+---
+"@khanacademy/perseus": patch
+"@khanacademy/perseus-editor": patch
+---
+
+Internal: Move graphing-agnostic, mathy functions in the interactive graph code to a math/ folder.
diff --git a/packages/perseus/src/widgets/interactive-graphs/axis-labels.tsx b/packages/perseus/src/widgets/interactive-graphs/axis-labels.tsx
index 1aa4e8a081..ecd7559184 100644
--- a/packages/perseus/src/widgets/interactive-graphs/axis-labels.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/axis-labels.tsx
@@ -4,6 +4,7 @@ import React from "react";
import {getDependencies} from "../../dependencies";
import {pointToPixel} from "./graphs/use-transform";
+import {MAX, X, Y} from "./math";
import useGraphConfig from "./reducer/use-graph-config";
import type {GraphDimensions} from "./types";
@@ -11,8 +12,8 @@ import type {GraphDimensions} from "./types";
export default function AxisLabels() {
const {range, labels, width, height} = useGraphConfig();
- const yAxisLabelLocation: vec.Vector2 = [0, range[1][1]];
- const xAxisLabelLocation: vec.Vector2 = [range[0][1], 0];
+ const yAxisLabelLocation: vec.Vector2 = [0, range[Y][MAX]];
+ const xAxisLabelLocation: vec.Vector2 = [range[X][MAX], 0];
const [xAxisLabelText, yAxisLabelText] = labels;
const graphInfo: GraphDimensions = {
diff --git a/packages/perseus/src/widgets/interactive-graphs/axis-ticks.tsx b/packages/perseus/src/widgets/interactive-graphs/axis-ticks.tsx
index b0644b104a..2c5ec61e66 100644
--- a/packages/perseus/src/widgets/interactive-graphs/axis-ticks.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/axis-ticks.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import {useTransformVectorsToPixels} from "./graphs/use-transform";
+import {MAX, MIN, X, Y} from "./math";
import useGraphConfig from "./reducer/use-graph-config";
import type {GraphDimensions} from "./types";
@@ -18,13 +19,13 @@ const YGridTick = ({y, graphInfo}: {y: number; graphInfo: GraphDimensions}) => {
// If the graph is zoomed in, we want to make sure the ticks are still visible
// even if they are outside the graph's range.
- if (graphInfo.range[0][0] > 0) {
+ if (graphInfo.range[X][MIN] > 0) {
// If the graph is on the positive side of the x-axis, lock the ticks to the left side of the graph
- xPointOnAxis = graphInfo.range[0][0];
+ xPointOnAxis = graphInfo.range[X][MIN];
}
- if (graphInfo.range[0][1] < 0) {
+ if (graphInfo.range[X][MAX] < 0) {
// If the graph is on the negative side of the x-axis, lock the ticks to the right side of the graph
- xPointOnAxis = graphInfo.range[0][1];
+ xPointOnAxis = graphInfo.range[X][MAX];
}
const pointOnAxis: vec.Vector2 = [xPointOnAxis, y];
@@ -57,13 +58,13 @@ const XGridTick = ({x, graphInfo}: {x: number; graphInfo: GraphDimensions}) => {
let yPointOnAxis = 0;
// If the graph is zoomed in, we want to make sure the ticks are still visible
// even if they are outside the graph's range.
- if (graphInfo.range[1][0] > 0) {
+ if (graphInfo.range[Y][MIN] > 0) {
// If the graph is on the positive side of the y-axis, lock the ticks to the top of the graph
- yPointOnAxis = graphInfo.range[1][0];
+ yPointOnAxis = graphInfo.range[Y][MIN];
}
- if (graphInfo.range[1][1] < 0) {
+ if (graphInfo.range[Y][MAX] < 0) {
// If the graph is on the negative side of the x-axis, lock the ticks to the bottom of the graph
- yPointOnAxis = graphInfo.range[1][1];
+ yPointOnAxis = graphInfo.range[Y][MAX];
}
const pointOnAxis: vec.Vector2 = [x, yPointOnAxis];
@@ -119,11 +120,12 @@ export const AxisTicks = () => {
height,
};
- const [xMin, xMax] = range[0];
- const [yMin, yMax] = range[1];
+ // TODO(benchristel): destructure these in one line
+ const [xMin, xMax] = range[X];
+ const [yMin, yMax] = range[Y];
- const yTickStep = tickStep[1];
- const xTickStep = tickStep[0];
+ const yTickStep = tickStep[Y];
+ const xTickStep = tickStep[X];
const yGridTicks = generateTickLocations(yTickStep, yMin, yMax);
const xGridTicks = generateTickLocations(xTickStep, xMin, xMax);
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx
index 8afbbd671d..65ded772f0 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx
@@ -2,10 +2,10 @@ import {vec} from "mafs";
import * as React from "react";
import {useRef} from "react";
+import {snap, X, Y} from "../math";
import {moveCenter, moveRadiusPoint} from "../reducer/interactive-graph-action";
import {getRadius} from "../reducer/interactive-graph-state";
import useGraphConfig from "../reducer/use-graph-config";
-import {snap} from "../utils";
import {StyledMovablePoint} from "./components/movable-point";
import {useDraggable} from "./use-draggable";
@@ -68,17 +68,17 @@ function MovableCircle(props: {
>
@@ -97,10 +97,10 @@ function DragHandle(props: {center: [x: number, y: number]}) {
<>
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/arrowhead.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/arrowhead.tsx
index da54727455..4877c8bdef 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/arrowhead.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/arrowhead.tsx
@@ -2,6 +2,7 @@ import {type vec} from "mafs";
import * as React from "react";
import {pathBuilder} from "../../../../util/svg";
+import {X, Y} from "../../math";
import {useTransformVectorsToPixels} from "../use-transform";
type Props = {
@@ -27,7 +28,7 @@ export function Arrowhead(props: Props) {
return (
{
const graphConfig = useGraphConfig();
const {tickStep, range} = graphConfig;
- const [xMin, xMax] = range[0];
- const [yMin, yMax] = range[1];
+ // TODO(benchristel): use destructuring here
+ const [xMin, xMax] = range[X];
+ const [yMin, yMax] = range[Y];
- const yTickStep = tickStep[1];
- const xTickStep = tickStep[0];
+ const yTickStep = tickStep[Y];
+ const xTickStep = tickStep[X];
const yGridTicks = generateTickLocations(yTickStep, yMin, yMax);
const xGridTicks = generateTickLocations(xTickStep, xMin, xMax);
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-line.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-line.test.tsx
index 661d326e38..848c095294 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-line.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-line.test.tsx
@@ -9,7 +9,7 @@ import {MovableLine, trimRange} from "./movable-line";
import type {Interval, vec} from "mafs";
describe("trimRange", () => {
- it("does not trim smaller than [[0, 0], [0, 0]]", () => {
+ it("does not trim ranges below a size of 0", () => {
const graphDimensionsInPixels: vec.Vector2 = [1, 1];
const range: [Interval, Interval] = [
[0, 1],
@@ -19,8 +19,8 @@ describe("trimRange", () => {
const trimmed = trimRange(range, graphDimensionsInPixels);
expect(trimmed).toEqual([
- [0, 0],
- [0, 0],
+ [0.5, 0.5],
+ [0.5, 0.5],
]);
});
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-line.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-line.tsx
index ef39027d3d..aecdfb2f44 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-line.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-line.tsx
@@ -2,8 +2,9 @@ import {vec} from "mafs";
import {useRef, useState} from "react";
import * as React from "react";
+import {inset, snap, size} from "../../math";
import useGraphConfig from "../../reducer/use-graph-config";
-import {snap, TARGET_SIZE} from "../../utils";
+import {TARGET_SIZE} from "../../utils";
import {useDraggable} from "../use-draggable";
import {useTransformVectorsToPixels} from "../use-transform";
import {getIntersectionOfRayWithBox} from "../utils";
@@ -227,16 +228,5 @@ export function trimRange(
const graphUnitsPerPixelY = size(yRange) / pixelsTall;
const graphUnitsToTrimX = pixelsToTrim * graphUnitsPerPixelX;
const graphUnitsToTrimY = pixelsToTrim * graphUnitsPerPixelY;
- return [trim(xRange, graphUnitsToTrimX), trim(yRange, graphUnitsToTrimY)];
-}
-
-function trim(interval: Interval, amount: number): Interval {
- if (size(interval) < amount * 2) {
- return [0, 0];
- }
- return [interval[0] + amount, interval[1] - amount];
-}
-
-function size(interval: Interval): number {
- return interval[1] - interval[0];
+ return inset([graphUnitsToTrimX, graphUnitsToTrimY], range);
}
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-point-view.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-point-view.tsx
index 2db0dc1d77..453b49d6e3 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-point-view.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-point-view.tsx
@@ -3,6 +3,7 @@ import Tooltip from "@khanacademy/wonder-blocks-tooltip";
import * as React from "react";
import {forwardRef} from "react";
+import {X, Y} from "../../math";
import useGraphConfig from "../../reducer/use-graph-config";
import {useTransformVectorsToPixels} from "../use-transform";
@@ -56,8 +57,9 @@ export const MovablePointView = forwardRef(
const [[x, y]] = useTransformVectorsToPixels(point);
- const [xMin, xMax] = range[0];
- const [yMin, yMax] = range[1];
+ // TODO(benchristel): destructure range in one line
+ const [xMin, xMax] = range[X];
+ const [yMin, yMax] = range[Y];
const [[verticalStartX]] = useTransformVectorsToPixels([xMin, 0]);
const [[verticalEndX]] = useTransformVectorsToPixels([xMax, 0]);
@@ -119,7 +121,7 @@ export const MovablePointView = forwardRef(
{svgForPoint}
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-point.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-point.tsx
index 9eb90d278f..aa25bb7eb5 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-point.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-point.tsx
@@ -2,8 +2,8 @@ import {color as WBColor} from "@khanacademy/wonder-blocks-tokens";
import * as React from "react";
import {useRef} from "react";
+import {snap} from "../../math";
import useGraphConfig from "../../reducer/use-graph-config";
-import {snap} from "../../utils";
import {useDraggable} from "../use-draggable";
import {MovablePointView} from "./movable-point-view";
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/svg-line.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/svg-line.tsx
index 75513d4982..e77800e1df 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/svg-line.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/svg-line.tsx
@@ -1,5 +1,7 @@
import * as React from "react";
+import {X, Y} from "../../math";
+
import type {vec} from "mafs";
import type {SVGProps} from "react";
@@ -15,10 +17,10 @@ export function SVGLine(props: Props) {
const {start, end, style, className, testId} = props;
return (
{
return props.markings === "none" ? null : (
<>
{
// Only render the axis ticks and arrows if the markings are set to a full "graph"
diff --git a/packages/perseus/src/widgets/interactive-graphs/legacy-grid.tsx b/packages/perseus/src/widgets/interactive-graphs/legacy-grid.tsx
index 9e0903f2b6..e909772d2c 100644
--- a/packages/perseus/src/widgets/interactive-graphs/legacy-grid.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/legacy-grid.tsx
@@ -5,6 +5,8 @@ import AssetContext from "../../asset-context";
import {SvgImage} from "../../components";
import {interactiveSizes} from "../../styles/constants";
+import {X} from "./math";
+
import type {PerseusImageBackground} from "../../perseus-types";
interface Props {
@@ -19,7 +21,7 @@ interface Props {
export const LegacyGrid = ({box, backgroundImage}: Props) => {
const {url, width, height} = backgroundImage ?? {};
if (url && typeof url === "string") {
- const scale = box[0] / interactiveSizes.defaultBoxSize;
+ const scale = box[X] / interactiveSizes.defaultBoxSize;
return (
{
{line}
{showPoint1 && (
{
)}
{showPoint2 && (
{
points.map((point, index) => (
))}
diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx
index f8ab343b84..0ce5c85788 100644
--- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.tsx
@@ -21,6 +21,7 @@ import {SvgDefs} from "./graphs/components/text-label";
import {PointGraph} from "./graphs/point";
import {Grid} from "./grid";
import {LegacyGrid} from "./legacy-grid";
+import {X, Y} from "./math";
import {Protractor} from "./protractor";
import {initializeGraphState} from "./reducer/initialize-graph-state";
import {
@@ -248,8 +249,8 @@ export const MafsGraph = (props: MafsGraphProps) => {
{
+ it("does nothing given amount [0, 0]", () => {
+ const box: Box = [
+ [1, 2],
+ [3, 4],
+ ];
+ expect(inset([0, 0], box)).toEqual([
+ [1, 2],
+ [3, 4],
+ ]);
+ });
+
+ it("does not mutate the given box", () => {
+ const box: Box = [
+ [0, 9],
+ [1, 10],
+ ];
+ inset([1, 1], box);
+ expect(box).toEqual([
+ [0, 9],
+ [1, 10],
+ ]);
+ });
+
+ it("shrinks the box horizontally", () => {
+ const box: Box = [
+ [0, 9],
+ [1, 10],
+ ];
+ expect(inset([1, 0], box)).toEqual([
+ [1, 8],
+ [1, 10],
+ ]);
+ });
+
+ it("shrinks the box vertically", () => {
+ const box: Box = [
+ [0, 9],
+ [1, 10],
+ ];
+ expect(inset([0, 1], box)).toEqual([
+ [0, 9],
+ [2, 9],
+ ]);
+ });
+
+ it("shrinks in both dimensions", () => {
+ const box: Box = [
+ [0, 9],
+ [1, 10],
+ ];
+ expect(inset([1, 1], box)).toEqual([
+ [1, 8],
+ [2, 9],
+ ]);
+ });
+
+ it("expands the box given a negative amount", () => {
+ const box: Box = [
+ [0, 9],
+ [1, 10],
+ ];
+ expect(inset([-1, -1], box)).toEqual([
+ [-1, 10],
+ [0, 11],
+ ]);
+ });
+
+ it("does not create an invalid box if the amount is too big", () => {
+ const box: Box = [
+ [0, 1],
+ [0, 1],
+ ];
+ expect(inset([9, 9], box)).toEqual([
+ [0.5, 0.5],
+ [0.5, 0.5],
+ ]);
+ });
+});
diff --git a/packages/perseus/src/widgets/interactive-graphs/math/box.ts b/packages/perseus/src/widgets/interactive-graphs/math/box.ts
new file mode 100644
index 0000000000..d97e3c4b2f
--- /dev/null
+++ b/packages/perseus/src/widgets/interactive-graphs/math/box.ts
@@ -0,0 +1,20 @@
+import {clamp} from "./clamp";
+import {X, Y} from "./coordinates";
+import {trim} from "./interval";
+
+import type {Interval, vec} from "mafs";
+
+export type Box = [x: Interval, y: Interval];
+
+// Restricts the `point` to be within `box`, by clamping each coordinate to the
+// corresponding interval.
+export function clampToBox(box: Box, point: vec.Vector2): vec.Vector2 {
+ return [clamp(point[X], ...box[X]), clamp(point[Y], ...box[Y])];
+}
+
+// Reduces the size of `box`, trimming each corner by the given positive
+// `amount`. If a component of `amount` is negative, the box is expanded in
+// that dimension instead.
+export function inset(amount: vec.Vector2, box: Box): Box {
+ return [trim(amount[X], box[X]), trim(amount[Y], box[Y])];
+}
diff --git a/packages/perseus/src/widgets/interactive-graphs/math/clamp.ts b/packages/perseus/src/widgets/interactive-graphs/math/clamp.ts
new file mode 100644
index 0000000000..b67e6993ac
--- /dev/null
+++ b/packages/perseus/src/widgets/interactive-graphs/math/clamp.ts
@@ -0,0 +1,10 @@
+// Restricts `value` to be between `min` and `max`.
+export function clamp(value: number, min: number, max: number): number {
+ if (value < min) {
+ return min;
+ }
+ if (value > max) {
+ return max;
+ }
+ return value;
+}
diff --git a/packages/perseus/src/widgets/interactive-graphs/math/coordinates.ts b/packages/perseus/src/widgets/interactive-graphs/math/coordinates.ts
new file mode 100644
index 0000000000..745558f48d
--- /dev/null
+++ b/packages/perseus/src/widgets/interactive-graphs/math/coordinates.ts
@@ -0,0 +1,4 @@
+// Use X and Y to index into arrays that represent coordinate pairs.
+// e.g. point[X]
+export const X = 0;
+export const Y = 1;
diff --git a/packages/perseus/src/widgets/interactive-graphs/math/index.ts b/packages/perseus/src/widgets/interactive-graphs/math/index.ts
new file mode 100644
index 0000000000..a5e69cee93
--- /dev/null
+++ b/packages/perseus/src/widgets/interactive-graphs/math/index.ts
@@ -0,0 +1,5 @@
+export {clamp} from "./clamp";
+export {snap} from "./snap";
+export {inset, clampToBox} from "./box";
+export {X, Y} from "./coordinates";
+export {MIN, MAX, size} from "./interval";
diff --git a/packages/perseus/src/widgets/interactive-graphs/math/interval.test.ts b/packages/perseus/src/widgets/interactive-graphs/math/interval.test.ts
new file mode 100644
index 0000000000..397f5392e5
--- /dev/null
+++ b/packages/perseus/src/widgets/interactive-graphs/math/interval.test.ts
@@ -0,0 +1,23 @@
+import {trim} from "./interval";
+
+describe("trim", () => {
+ it("does nothing given amount 0", () => {
+ const result = trim(0, [2, 5]);
+ expect(result).toEqual([2, 5]);
+ });
+
+ it("removes the given amount from each end of the interval", () => {
+ const result = trim(1, [2, 5]);
+ expect(result).toEqual([3, 4]);
+ });
+
+ it("does not let the size of the interval go below zero", () => {
+ const result = trim(9, [2, 5]);
+ expect(result).toEqual([3.5, 3.5]);
+ });
+
+ it("expands the interval when amount is negative", () => {
+ const result = trim(-1, [2, 5]);
+ expect(result).toEqual([1, 6]);
+ });
+});
diff --git a/packages/perseus/src/widgets/interactive-graphs/math/interval.ts b/packages/perseus/src/widgets/interactive-graphs/math/interval.ts
new file mode 100644
index 0000000000..6435ab9f11
--- /dev/null
+++ b/packages/perseus/src/widgets/interactive-graphs/math/interval.ts
@@ -0,0 +1,29 @@
+import type {Interval} from "mafs";
+
+// Use MIN and MAX to index into arrays that represent intervals.
+// e.g. range[MAX]
+export const MIN = 0;
+export const MAX = 1;
+
+export function size([min, max]: Interval): number {
+ return max - min;
+}
+
+// Removes an equal amount from each end of the interval.
+//
+// If the given amount cannot be removed (because the interval is too small),
+// half the interval's size is removed from each end instead, so the interval
+// ends up with size = 0, and both MAX and MIN equal to its former midpoint.
+//
+// If `amount` is negative, the interval is expanded instead.
+export function trim(amount: number, interval: Interval): Interval {
+ if (amount * 2 > size(interval)) {
+ const middle = average(...interval);
+ return [middle, middle];
+ }
+ return [interval[MIN] + amount, interval[MAX] - amount];
+}
+
+function average(a: number, b: number): number {
+ return (a + b) / 2;
+}
diff --git a/packages/perseus/src/widgets/interactive-graphs/math/snap.test.ts b/packages/perseus/src/widgets/interactive-graphs/math/snap.test.ts
new file mode 100644
index 0000000000..c24ff5b21e
--- /dev/null
+++ b/packages/perseus/src/widgets/interactive-graphs/math/snap.test.ts
@@ -0,0 +1,15 @@
+import {snap} from "./snap";
+
+describe("snap", () => {
+ it("snaps to next position when over halfway to it", () => {
+ const result = snap([2, 2], [1.1, 1.1]);
+
+ expect(result).toEqual([2, 2]);
+ });
+
+ it("does not snap to next position when less than halfway to it", () => {
+ const result = snap([2, 2], [0.9, 0.9]);
+
+ expect(result).toEqual([0, 0]);
+ });
+});
diff --git a/packages/perseus/src/widgets/interactive-graphs/math/snap.ts b/packages/perseus/src/widgets/interactive-graphs/math/snap.ts
new file mode 100644
index 0000000000..99b44b5456
--- /dev/null
+++ b/packages/perseus/src/widgets/interactive-graphs/math/snap.ts
@@ -0,0 +1,14 @@
+import type {vec} from "mafs";
+
+// Rounds each component of `point` to the nearest multiple of the
+// corresponding component of `snapStep`. E.g. if the snap step is [2, 3],
+// the x-coord of the point will be rounded to the nearest multiple of 2, and
+// the y-coord of the point will be rounded to the nearest multiple of 3.
+export function snap(snapStep: vec.Vector2, point: vec.Vector2): vec.Vector2 {
+ const [requestedX, requestedY] = point;
+ const [snapX, snapY] = snapStep;
+ return [
+ Math.round(requestedX / snapX) * snapX,
+ Math.round(requestedY / snapY) * snapY,
+ ];
+}
diff --git a/packages/perseus/src/widgets/interactive-graphs/protractor.tsx b/packages/perseus/src/widgets/interactive-graphs/protractor.tsx
index 46fa05bf3e..8ecc6e5462 100644
--- a/packages/perseus/src/widgets/interactive-graphs/protractor.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/protractor.tsx
@@ -8,6 +8,7 @@ import {pathBuilder} from "../../util/svg";
import {useDraggable} from "./graphs/use-draggable";
import {useTransformVectorsToPixels} from "./graphs/use-transform";
import {calculateAngleInDegrees} from "./graphs/utils";
+import {X, Y} from "./math";
import useGraphConfig from "./reducer/use-graph-config";
import {bound, TARGET_SIZE} from "./utils";
@@ -64,9 +65,9 @@ export function Protractor() {
return (
diff --git a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts
index 1e3deab3ea..514ce2970e 100644
--- a/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts
+++ b/packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts
@@ -16,7 +16,8 @@ import {
import GraphUtils from "../../../util/graph-utils";
import {polar} from "../../../util/graphie";
import {getQuadraticCoefficients} from "../graphs/quadratic";
-import {snap, bound, clamp} from "../utils";
+import {clamp, snap, X, Y} from "../math";
+import {bound} from "../utils";
import {initializeGraphState} from "./initialize-graph-state";
import {
@@ -294,7 +295,7 @@ function doMovePoint(
// vertical line. If they are, then we don't want to move the point
const newCoords: vec.Vector2[] = [...state.coords];
newCoords[action.index] = boundDestination;
- if (newCoords[0][0] === newCoords[1][0]) {
+ if (newCoords[0][X] === newCoords[1][X]) {
return state;
}
@@ -368,13 +369,13 @@ function doMoveCenter(
// if it otherwise would be off the chart
// ex: if the handle is on the right and we move the center
// to the rightmost position, move the handle to the left
- const [xMin, xMax] = state.range[0];
+ const [xMin, xMax] = state.range[X];
const [radX] = newRadiusPoint;
if (radX < xMin || radX > xMax) {
- const xJumpDist = (radX - constrainedCenter[0]) * 2;
+ const xJumpDist = (radX - constrainedCenter[X]) * 2;
const possibleNewX = radX - xJumpDist;
if (possibleNewX >= xMin && possibleNewX <= xMax) {
- newRadiusPoint[0] = possibleNewX;
+ newRadiusPoint[X] = possibleNewX;
}
}
@@ -398,11 +399,11 @@ function doMoveRadiusPoint(
): InteractiveGraphState {
switch (state.type) {
case "circle": {
- const [xMin, xMax] = state.range[0];
+ const [xMin, xMax] = state.range[X];
const nextRadiusPoint: vec.Vector2 = [
// Constrain to graph range
// The +0 is to convert -0 to +0
- Math.min(Math.max(xMin, action.destination[0] + 0), xMax),
+ clamp(action.destination[X] + 0, xMin, xMax),
state.center[1],
];
@@ -463,10 +464,10 @@ const getDeltaVertex = (
delta: vec.Vector2,
): vec.Vector2 => {
const [deltaX, deltaY] = delta;
- const maxXMove = Math.min(...maxMoves.map((move) => move[0]));
- const maxYMove = Math.min(...maxMoves.map((move) => move[1]));
- const minXMove = Math.max(...minMoves.map((move) => move[0]));
- const minYMove = Math.max(...minMoves.map((move) => move[1]));
+ const maxXMove = Math.min(...maxMoves.map((move) => move[X]));
+ const maxYMove = Math.min(...maxMoves.map((move) => move[Y]));
+ const minXMove = Math.max(...minMoves.map((move) => move[X]));
+ const minYMove = Math.max(...minMoves.map((move) => move[Y]));
const dx = clamp(deltaX, minXMove, maxXMove);
const dy = clamp(deltaY, minYMove, maxYMove);
return [dx, dy];
diff --git a/packages/perseus/src/widgets/interactive-graphs/utils.test.ts b/packages/perseus/src/widgets/interactive-graphs/utils.test.ts
index 91dfc76454..6003f70e7d 100644
--- a/packages/perseus/src/widgets/interactive-graphs/utils.test.ts
+++ b/packages/perseus/src/widgets/interactive-graphs/utils.test.ts
@@ -1,4 +1,4 @@
-import {normalizePoints, normalizeCoords, snap} from "./utils";
+import {normalizePoints, normalizeCoords} from "./utils";
import type {Coord} from "../../interactive2/types";
import type {GraphRange} from "../../perseus-types";
@@ -65,17 +65,3 @@ describe("normalizeCoords", () => {
expect(result).toEqual(expected);
});
});
-
-describe("snap", () => {
- it("snaps to next position when over halfway to it", () => {
- const result = snap([2, 2], [1.1, 1.1]);
-
- expect(result).toEqual([2, 2]);
- });
-
- it("does not snap to next position when less than halfway to it", () => {
- const result = snap([2, 2], [0.9, 0.9]);
-
- expect(result).toEqual([0, 0]);
- });
-});
diff --git a/packages/perseus/src/widgets/interactive-graphs/utils.ts b/packages/perseus/src/widgets/interactive-graphs/utils.ts
index e03f757990..d38cb16615 100644
--- a/packages/perseus/src/widgets/interactive-graphs/utils.ts
+++ b/packages/perseus/src/widgets/interactive-graphs/utils.ts
@@ -1,3 +1,5 @@
+import {clampToBox, inset, MIN, size} from "./math";
+
import type {Coord} from "../../interactive2/types";
import type {PerseusInteractiveGraphWidgetOptions} from "../../perseus-types";
import type {Interval, vec} from "mafs";
@@ -21,14 +23,12 @@ export const normalizePoints = (
coords.map((coord, i) => {
const axisRange = range[i];
if (noSnap) {
- return axisRange[0] + (axisRange[1] - axisRange[0]) * coord;
+ return axisRange[MIN] + size(axisRange) * coord;
}
const axisStep = step[i];
- const nSteps = Math.floor(
- (axisRange[1] - axisRange[0]) / axisStep,
- );
+ const nSteps = Math.floor(size(axisRange) / axisStep);
const tick = Math.round(coord * nSteps);
- return axisRange[0] + axisStep * tick;
+ return axisRange[MIN] + axisStep * tick;
}) as Coord,
) as any;
@@ -40,20 +40,10 @@ export const normalizeCoords = (
coordsList.map(
(coords) =>
coords.map((coord, i) => {
- const extent = ranges[i][1] - ranges[i][0];
- return (coord + ranges[i][1]) / extent;
+ return (coord + ranges[i][1]) / size(ranges[i]);
}) as Coord,
) as any;
-export function snap(snapStep: vec.Vector2, point: vec.Vector2): vec.Vector2 {
- const [requestedX, requestedY] = point;
- const [snapX, snapY] = snapStep;
- return [
- Math.round(requestedX / snapX) * snapX,
- Math.round(requestedY / snapY) * snapY,
- ];
-}
-
// Returns the closest point to the given `point` that is within the graph
// bounds given in `state`.
export function bound({
@@ -65,21 +55,6 @@ export function bound({
range: [Interval, Interval];
point: vec.Vector2;
}): vec.Vector2 {
- const [requestedX, requestedY] = point;
- const [snapX, snapY] = snapStep;
- const [[minX, maxX], [minY, maxY]] = range;
- return [
- clamp(requestedX, minX + snapX, maxX - snapX),
- clamp(requestedY, minY + snapY, maxY - snapY),
- ];
-}
-
-export function clamp(value: number, min: number, max: number) {
- if (value < min) {
- return min;
- }
- if (value > max) {
- return max;
- }
- return value;
+ const boundingBox = inset(snapStep, range);
+ return clampToBox(boundingBox, point);
}