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); }