Movable points
Movable points can be dragged around the coordinate plane, or moved via the keyboard. They're the cornerstone of lots of interactions.
They can be unconstrained (allowed to move freely), constrained horizontally or vertically, or constrained to an arbitrary function. This example constrains movement horizontally:
import { Mafs, Plot, Point, Coordinates, useMovablePoint } from "mafs"
import range from "lodash/range"
function PointsAlongFunction() {
const fn = (x: number) => (x / 2) ** 2
const sep = useMovablePoint([1, 0], {
constrain: "horizontal",
})
const n = 10
const points =
sep.x != 0
? range(-n * sep.x, (n + 0.5) * sep.x, sep.x)
: []
return (
<Mafs
viewBox={{ x: [0, 0], y: [-1.3, 4.7] }}
>
<Coordinates.Cartesian />
<Plot.OfX y={fn} opacity={0.25} />
{points.map((x, index) => (
<Point x={x} y={fn(x)} key={index} />
))}
{sep.element}
</Mafs>
)
}
Constraints
Beyond constraining horizontally or vertically, points can also be constrained to arbitrary paths. This is done by passing a function to constrain
. The function is expected to take a point (x, y), which is where the user is trying to move to, and to return a new point, (x', y'), which is where the point should actually go.
To demonstrate this, imagine constraining a point to "snap" to the nearest whole number point. We take where the user is trying to move to, and round it to the nearest whole number.
useMovablePoint([0, 0], {
constrain: ([x, y]) => [Math.round(x), Math.round(y)]
})
Another common use case is to constrain motion to be circular—vec.withMag
comes in handy there.
useMovablePoint([0, 0], {
// Constrain `point` to move in a circle of radius 1
constrain: (point) => vec.withMag(point, 1)
})
You can also constrain a point to follow a straight line by setting constrain
to "horizontal"
or "vertical"
.
Mafs may call constrain
more than once when the user moves a point using the arrow keys, so it should be side-effect free.
Transformations and constraints
When wrapping a Movable Point in a Transform, the point will be transformed too. However, your constrain
function will be passed the untransformed point, and its return value will be transformed back into the currently applied transform. In other words, Mafs takes care of the math for you.
Let's see a more complex example where we combine more interesting constraint functions with transforms. On the left, we have a point that can only move in whole-number increments within a square, and on the right, a point that can only move in π/16 increments in a circle.
import { Mafs, Transform, Vector, Coordinates, useMovablePoint, Circle, Polygon, vec, Theme } from "mafs"
import clamp from "lodash/clamp"
function SnapPoint() {
return (
<Mafs viewBox={{ x: [-8, 8], y: [-2, 2] }}>
<Coordinates.Cartesian
xAxis={{ labels: false, axis: false }}
/>
<Transform translate={[-3, 0]}>
<Grid />
</Transform>
<Transform translate={[3, 0]}>
<Radial />
</Transform>
</Mafs>
)
}
function Grid() {
const gridMotion = useMovablePoint([1, 1], {
// Constrain this point to whole numbers inside of a rectangle
constrain: ([x, y]) => [
clamp(Math.round(x), -2, 2),
clamp(Math.round(y), -2, 2),
],
})
return (
<>
<Vector tail={[0, 0]} tip={gridMotion.point} />
<Polygon
points={[[-2, -2], [2, -2], [2, 2], [-2, 2]]}
color={Theme.blue}
/>
{gridMotion.element}
</>
)
}
function Radial() {
const radius = 2
const radialMotion = useMovablePoint([0, radius], {
// Constrain this point to specific angles from the center
constrain: (point) => {
const angle = Math.atan2(point[1], point[0])
const snap = Math.PI / 16
const roundedAngle = Math.round(angle / snap) * snap
return vec.rotate([radius, 0], roundedAngle)
},
})
return (
<>
<Circle
center={[0, 0]}
radius={radius}
color={Theme.blue}
fillOpacity={0}
/>
<Vector tail={[0, 0]} tip={radialMotion.point} />
{radialMotion.element}
</>
)
}
the following section isAdvancedUsing MovablePoint directly
useMovablePoint
is a hook that helps you instantiate and manage the state of aMovablePoint
. However, if need be, you can also use MovablePoint
directly. This can be useful if you need to work with a dynamic number of movable points (since the React "rules of hooks" ban you from dynamically calling hooks).
import { Mafs, Coordinates, MovablePoint, useMovablePoint, Line, Theme, vec } from "mafs"
import range from "lodash/range"
function DynamicMovablePoints() {
const start = useMovablePoint([-3, -1])
const end = useMovablePoint([3, 1])
function shift(shiftBy: vec.Vector2) {
start.setPoint(vec.add(start.point, shiftBy))
end.setPoint(vec.add(end.point, shiftBy))
}
const length = vec.dist(start.point, end.point)
const betweenPoints = range(1, length - 0.5, 1).map((t) =>
vec.lerp(start.point, end.point, t / length)
)
return (
<Mafs>
<Coordinates.Cartesian />
<Line.Segment
point1={start.point}
point2={end.point}
/>
{start.element}
{betweenPoints.map((point, i) => (
<MovablePoint
key={i}
point={point}
color={Theme.blue}
onMove={(newPoint) => {
shift(vec.sub(newPoint, point))
}}
/>
))}
{end.element}
</Mafs>
)
}
Props
<MovablePoint ... />
Name | Description | Default |
---|---|---|
point | The current position Vector2 | — |
onMove | A callback that is called as the user moves the point. (point: Vector2) => void | — |
constrain | Constrain the point to only horizontal movement, vertical movement, or mapped movement. In mapped movement mode, you must provide a function that maps the user's mouse position
ConstraintFunction | (point) => point |
color | string | var(--mafs-pink) |