v0.18.4

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 isAdvanced
Using MovablePoint directly

useMovablePoint is a hook that helps you instantiate and manage the state of a MovablePoint. 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 ... />
View on GitHub
NameDescriptionDefault
point*

The current position [x, y] of the point.

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 [x, y] to the position the point should "snap" to.

ConstraintFunction
(point) => point
color
string
var(--mafs-pink)