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, FunctionGraph, Point, CartesianCoordinates, 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 points =
sep.x != 0
? range(0, 10 * sep.x, sep.x).concat(
range(0, -10 * sep.x, -sep.x)
)
: []
return (
<Mafs yAxisExtent={[-1.3, 4.7]}>
<CartesianCoordinates />
<FunctionGraph.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.
The simplest example of this is, in fact, constraining movement horizontally and vertically. When you pass "horizontal"
or "vertical"
, Mafs is internally using functions like this to constrain the point:
- Horizontal constraint:
([newX, _]) => [newX, y]
- Vertical constraint:
([_, newY]) => [x, newY]
You can also round point positions to make points move discretely rather than continuously. The following example plays with that idea both for linear positions and angles.
import { Mafs, Vector, CartesianCoordinates, useMovablePoint, Circle, Polygon } from "mafs"
import * as vec from "vec-la"
import clamp from "lodash.clamp"
function SnapPoint() {
const gridMotion = useMovablePoint([-2, 1], {
// Constrain this point to whole numbers inside of a rectangle
constrain: ([x, y]) => [
clamp(Math.round(x), -5, -1),
clamp(Math.round(y), -2, 2),
],
})
const radius = 2
const shift = vec.matrixBuilder().translate(3, 0).get()
const radialMotion = useMovablePoint([0, radius], {
// Constrain this point to specific angles from the center
constrain: (point) => {
const angle = Math.PI / 2 - Math.atan2(...point)
const snap = Math.PI / 16
const roundedAngle = Math.round(angle / snap) * snap
return vec.rotate([radius, 0], roundedAngle)
},
// (More on transforms in the next section)
transform: shift,
})
return (
<Mafs
xAxisExtent={[-8.5, 8.5]}
yAxisExtent={[-3, 3]}
>
<CartesianCoordinates xAxis={{ labels: false }} />
<Vector tail={[-3, 0]} tip={gridMotion.point} />
<Polygon
points={[[-5, -2], [-1, -2], [-1, 2], [-5, 2]]}
fillOpacity={0}
strokeOpacity={0.5}
strokeStyle="dashed"
/>
<Circle
center={vec.transform([0, 0], shift)}
radius={radius}
fillOpacity={0}
strokeOpacity={0.5}
strokeStyle="dashed"
/>
<Vector
tail={vec.transform([0, 0], shift)}
tip={vec.transform(radialMotion.point, shift)}
/>
{gridMotion.element}
{radialMotion.element}
</Mafs>
)
}
Transforms
Transforms are a way to separate a point's visual location from its mathematical one. This is great if you want to define a point's position in terms of other points.
When transforming a point, its constraints are also transformed—your constraint
function, if any, will receive the point's actual position, not its visual one. This means that if you rotate a point using a transformation, and you've constrained the point horizontally (for example), the horizontal constraint will be rotated, too.
This next example exploits this behavior to great effect. The center (orange) point is used to translate the other 3 points, so they "follow" the center point. Then, the outer (blue) point, which rotates the ellipse, applies a rotation to the width and height points. All along the way, every point can be treated as if it hasn't moved at all, keeping the math easy for us and letting Mafs do all the linear algebra.
import { Mafs, Ellipse, Circle, CartesianCoordinates, useMovablePoint, Theme, } from "mafs"
import * as vec from "vec-la"
function MovableEllipse() {
const rotationHintRadius = 3
// This center point translates everything else.
const center = useMovablePoint([0, 0], {
color: Theme.orange,
})
const translation = vec
.matrixBuilder()
.translate(center.x, center.y)
.get()
// This outer point rotates the ellipse, and
// is also translated by the center point.
// Its position is used to build a rotation matrix.
const rotate = useMovablePoint([rotationHintRadius, 0], {
color: Theme.blue,
transform: translation,
// Constrain this point to only move in a circle
constrain: (position) =>
vec.scale(vec.norm(position), rotationHintRadius),
})
const angle = Math.PI / 2 - Math.atan2(...rotate.point)
const rotation = vec.matrixBuilder().rotate(angle).get()
// Lastly, these two points are rotated and translated
// according to the outer two points.
const width = useMovablePoint([2, 0], {
transform: vec.composeTransform(translation, rotation),
constrain: "horizontal",
})
const height = useMovablePoint([0, 1], {
transform: vec.composeTransform(translation, rotation),
constrain: "vertical",
})
return (
<Mafs>
<CartesianCoordinates />
{/*
* Display a little hint that the
* point is meant to move radially
*/}
<Circle
center={center.point}
radius={rotationHintRadius}
strokeStyle="dashed"
strokeOpacity={0.3}
fillOpacity={0}
/>
<Ellipse
center={center.point}
radius={[Math.abs(width.x), Math.abs(height.y)]}
angle={angle}
/>
{center.element}
{width.element}
{height.element}
{rotate.element}
</Mafs>
)
}
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, CartesianCoordinates, MovablePoint, Vector2, useMovablePoint, Line, Theme } from "mafs"
import * as vec from "vec-la"
function DynamicMovablePoints() {
const startPoint = useMovablePoint([-3, -1])
const endPoint = useMovablePoint([3, 1])
const length = vec.dist(startPoint.point, endPoint.point)
const numPointsInBetween = length
function shift(shiftBy: Vector2) {
startPoint.setPoint(vec.add(startPoint.point, shiftBy))
endPoint.setPoint(vec.add(endPoint.point, shiftBy))
}
return (
<Mafs>
<CartesianCoordinates />
<Line.Segment
point1={startPoint.point}
point2={endPoint.point}
/>
{new Array(Math.round(numPointsInBetween))
.fill(0)
.map((_, i) => {
if (i === 0 || i === numPointsInBetween)
return null
const point = vec.towards(
startPoint.point,
endPoint.point,
i / numPointsInBetween
)
return (
<MovablePoint
key={i}
point={point}
color={Theme.blue}
onMove={(newPoint) => {
shift(vec.sub(newPoint, point))
}}
/>
)
})}
{startPoint.element}
{endPoint.element}
</Mafs>
)
}