Transform contexts

At its core, Mafs is just SVG with two contextual transforms. Those transforms correspond to two things:

  • The view transform, which maps from world space to pixel space.
  • The user transform, which is imposed by the Transform component.

The general approach is that, to render a point (x, y), you must first apply the user transform (because, well, the user is trying to move your component in some way), and then the view transform (so that it gets rendered by the SVG renderer in the right spot).

Mafs provides these transforms through two means:

  • The --mafs-view-transform and --mafs-user-transform CSS custom properties, which can be applied to an SVG element's style attribute.
  • The useTransformContext hook, which returns an object containing the viewTransform matrix and the userTransform matrix.

Components can mix and match these two approaches depending on needs. For example, the Text component transforms its anchor point in JavaScript, and doesn't apply any CSS transforms, because that would distort the text itself. On the other hand, the Ellipse component almost entirely relies on CSS transforms internally.

Accessing transforms in CSS

Here's an example of a custom component that uses the CSS transforms approach to render a delicious little PizzaSlice. The slice is wrapped in Debug.TransformWidget component so that you can try applying some user transforms it.

import { Mafs, Coordinates, Debug } from "mafs"
import * as React from "react"
function Example() {
  return (
    <Mafs viewBox={{ y: [-1, 1], x: [-1, 1] }}>
      <Coordinates.Cartesian />

      <Debug.TransformWidget>
        <PizzaSlice />
      </Debug.TransformWidget>
    </Mafs>
  )
}

function PizzaSlice() {
  const maskId = `pizza-slice-mask-${React.useId()}`

  return (
    <g
      style={{
        transform: `var(--mafs-view-transform) var(--mafs-user-transform)`,
      }}
    >
      <defs>
        <mask id={maskId}>
          <polyline points={`0,0 ${1},${1 / 2} ${1},${-1 / 2}`} fill="white" />
        </mask>
      </defs>

      <g mask={`url(#${maskId})`}>
        <circle cx={0} cy={0} r={1} fill="brown" />
        <circle cx={0} cy={0} r={1 * 0.85} fill="yellow" />
        <circle cx={0.4} cy={1 * 0.1} r={0.11} fill="red" />
        <circle cx={0.2} cy={-1 * 0.1} r={0.09} fill="red" />
        <circle cx={0.5} cy={-1 * 0.15} r={0.1} fill="red" />
        <circle cx={0.7} cy={1 * 0.05} r={0.11} fill="red" />
        <circle cx={0.65} cy={1 * 0.35} r={0.1} fill="red" />
        <circle cx={0.65} cy={-1 * 0.37} r={0.08} fill="red" />
      </g>
    </g>
  )
}

This is an example of a component that gets entirely transformed by the user and view transforms. The pizza slice can end up totally distorted. For cases where you want to preserve the aspect ratio or pixel size of your component, you likely need to use the hooks approach.

Accessing transforms in JavaScript

Here's an example of a custom component that uses the hooks approach to render a grid of points. Because we want the grid's points to have a radius of 3 pixels (regardless of the viewport or any transforms), we use the useTransformContext hook to get the user and view transforms and apply them to the circles' x and y coordinates, but not to their radius (which is in pixels). We also cannot use the CSS transforms approach here, because that would distort each circle.

import { Coordinates, Debug, Mafs, useTransformContext, vec } from "mafs"

function Example() {
  return (
    <Mafs viewBox={{ y: [-1, 5], x: [-1, 6] }}>
      <Coordinates.Cartesian />

      <Debug.TransformWidget>
        <PointCloud />
      </Debug.TransformWidget>
    </Mafs>
  )
}

function PointCloud() {
  const { userTransform, viewTransform } =
    useTransformContext()

  const size = 5
  const perAxis = 10

  const points: { at: vec.Vector2; color: string }[] = []
  for (let i = 0; i <= size; i += size / perAxis) {
    for (let j = 0; j <= size; j += size / perAxis) {
      const userTransformedPoint = vec.transform([i, j], userTransform)
      const viewTransformedPoint = vec.transform(userTransformedPoint, viewTransform)

      const h = (360 * (i + j)) / (size * 2)
      const s = 100

      // If h is blueish, make the point lighter
      const l = h > 200 && h < 300 ? 70 : 50

      points.push({
        at: viewTransformedPoint,
        color: `hsl(${h} ${s}% ${l}%)`,
      })
    }
  }

  return (
    <>
      {points.map(({ at: [x, y], color }) => {
        return (
          <circle
            key={`${x},${y}`}
            cx={x}
            cy={y}
            r={3}
            fill={color}
            className="mafs-shadow"
          />
        )
      })}
    </>
  )
}