14 | This example was inspired by Freya Holmér's excellent{" "}
15 | video on Bézier curves.
16 |
17 |
18 |
13 | This example combines interaction and animation to show the freefall trajectory of a
14 | launched object. Some regular, HTML <button>s are used to start and stop
15 | the animation.
16 |
17 |
18 | (null)
14 | CoordinateContext.displayName = "CoordinateContext"
15 |
16 | export function useCoordinateContext(): CoordinateContextShape {
17 | const context = React.useContext(CoordinateContext)
18 | invariant(
19 | context,
20 | "CoordinateContext is not loaded. Are you rendering a Mafs component outside of Mafs?",
21 | )
22 |
23 | return context
24 | }
25 |
26 | export default CoordinateContext
27 |
--------------------------------------------------------------------------------
/docs/app/guides/display/polygons/page.tsx:
--------------------------------------------------------------------------------
1 | import CodeAndExample from "components/CodeAndExample"
2 |
3 | import PolygonExample from "guide-examples/PolygonExample"
4 | import PolylineExample from "guide-examples/PolylineExample"
5 | import { PropTable } from "components/PropTable"
6 | import type { Metadata } from "next"
7 |
8 | export const metadata: Metadata = {
9 | title: "Polygons",
10 | }
11 |
12 | function Polygons() {
13 | return (
14 | <>
15 | Polygons take a number of points and create a closed shape.
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | >
25 | )
26 | }
27 |
28 | export default Polygons
29 |
--------------------------------------------------------------------------------
/docs/components/guide-examples/debug/DebugExample.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Mafs, Coordinates, Plot } from "mafs"
5 |
6 | export default function Example() {
7 | const [debug, setDebug] = React.useState(true)
8 |
9 | return (
10 |
11 | {/* Set the `debug` prop on Mafs to get a bird's eye view. */}
12 |
13 |
14 | Math.sin(x * Math.PI)} />
15 |
16 |
17 | setDebug(e.target.checked)}
21 | />
22 | Debug
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/docs/helpers/fancyFx.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | export default function fancyFx(title: string): React.ReactNode {
3 | function FOfX() {
4 | return (
5 | <>
6 |
7 | ƒ(𝑥)
8 |
9 | f of x
10 | >
11 | )
12 | }
13 |
14 | const splitByFx = title.split("f(x)")
15 |
16 | return (
17 |
18 | {splitByFx
19 | .slice(0, -1)
20 | .map((piece, index) => [
21 | piece,
22 |
23 | {" "}
24 |
25 | ,
26 | ])
27 | .concat([splitByFx[splitByFx.length - 1]])
28 | .flat()}
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/docs/app/guides/custom-components/overview/page.tsx:
--------------------------------------------------------------------------------
1 | import PizzaMarch from "guide-examples/custom/pizza-march"
2 | import type { Metadata } from "next"
3 |
4 | export const metadata: Metadata = {
5 | title: "Custom components",
6 | }
7 |
8 | export default function CustomPage() {
9 | return (
10 | <>
11 |
12 | Sometimes, Mafs simply won't have the component you need. When that happens, Mafs provides
13 | APIs to drop one level deeper, letting you render any SVG elements you want. All it takes is
14 | some work to ensure things render correctly.
15 |
16 |
17 |
18 | In learning this, we'll make a PizzaSlice component that behaves just like a
19 | built-in Mafs component.
20 |
21 |
22 |
23 | >
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/docs/components/WIP.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | function WIP({ children }: React.PropsWithChildren) {
3 | return (
4 |
22 | )
23 | }
24 |
25 | export default WIP
26 |
--------------------------------------------------------------------------------
/docs/components/guide-examples/animation/AnimatedPoint.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import {
5 | Mafs,
6 | Point,
7 | Coordinates,
8 | useStopwatch,
9 | } from "mafs"
10 |
11 | export default function AnimatedPoint() {
12 | const { time, start } = useStopwatch()
13 |
14 | // Stopwatches are stopped initially, so we
15 | // can start it when the component mounts.
16 | // We declare `start` as a dependency of the
17 | // effect to make React happy, but Mafs
18 | // guarantees its identity will never change.
19 | React.useEffect(() => start(), [start])
20 |
21 | return (
22 |
23 |
24 |
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/docs/components/guide-examples/display/coordinates/CartesianCoordinatesConfigExample.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Mafs, Coordinates, labelPi } from "mafs"
4 |
5 | export default function CartesianCoordinatesExample() {
6 | return (
7 |
13 | (isOdd(n) ? n : ""),
17 | }}
18 | yAxis={{
19 | lines: Math.PI,
20 | subdivisions: 4,
21 | labels: labelPi,
22 | }}
23 | />
24 |
25 | )
26 | }
27 |
28 | function isOdd(n: number) {
29 | return ((n % 2) + 2) % 2 === 0
30 | }
31 |
--------------------------------------------------------------------------------
/docs/components/guide-examples/TextExample.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | // prettier-ignore
4 | import { Mafs, Coordinates, Text, useMovablePoint } from "mafs"
5 |
6 | export default function VectorExample() {
7 | const point = useMovablePoint([1, 1])
8 |
9 | return (
10 |
11 |
12 |
18 | ({point.x.toFixed(3)}, {point.y.toFixed(3)})
19 |
20 |
26 | ({point.x.toFixed(3)}, {point.y.toFixed(3)})
27 |
28 | {point.element}
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "paths": {
18 | "guide-examples/*": ["./components/guide-examples/*"],
19 | "components/*": ["./components/*"]
20 | },
21 | "plugins": [
22 | {
23 | "name": "next"
24 | }
25 | ]
26 | },
27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28 | "exclude": ["node_modules"]
29 | }
30 |
--------------------------------------------------------------------------------
/docs/components/guide-examples/plots/inequalities.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Mafs,
5 | Coordinates,
6 | Plot,
7 | Theme,
8 | useMovablePoint,
9 | } from "mafs"
10 |
11 | export default function InequalitiesExample() {
12 | const a = useMovablePoint([0, -1])
13 |
14 | return (
15 |
16 |
17 |
18 | Math.cos(y + a.y) - a.x,
21 | ">": (y) => Math.sin(y - a.y) + a.x,
22 | }}
23 | color={Theme.blue}
24 | />
25 |
26 | Math.cos(x + a.x) - a.y,
29 | ">": (x) => Math.sin(x - a.x) + a.y,
30 | }}
31 | color={Theme.pink}
32 | />
33 |
34 | {a.element}
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/docs/app/guides/display/text/page.tsx:
--------------------------------------------------------------------------------
1 | import CodeAndExample from "components/CodeAndExample"
2 |
3 | import TextExample from "guide-examples/TextExample"
4 | import { PropTable } from "components/PropTable"
5 | import type { Metadata } from "next"
6 |
7 | export const metadata: Metadata = {
8 | title: "Text",
9 | }
10 |
11 | function Text() {
12 | return (
13 | <>
14 |
15 | The Text component is a pretty lightweight wrapper around SVG's{" "}
16 | text, namely that the anchor point is mapped to coordinate space. The optional{" "}
17 | attach will orient the text along a cardinal direction ("n", "s", "nw", etc.)
18 |
19 |
20 |
21 |
22 |
23 | >
24 | )
25 | }
26 |
27 | export default Text
28 |
--------------------------------------------------------------------------------
/scripts/docgen.ts:
--------------------------------------------------------------------------------
1 | import fs from "node:fs"
2 | import { fileURLToPath } from "node:url"
3 | import path from "node:path"
4 | import type * as docgen from "react-docgen-typescript"
5 |
6 | export const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
7 |
8 | export function writeDocgenResults(docgenInfo: docgen.ComponentDoc[]) {
9 | const writePath = path.join(projectRoot, "docs/generated-docgen.tsx")
10 |
11 | docgenInfo = docgenInfo.map((inf) => ({
12 | ...inf,
13 | filePath: path.relative(projectRoot, inf.filePath),
14 | }))
15 |
16 | fs.writeFileSync(
17 | writePath,
18 | [
19 | `// prettier-ignore`,
20 | `const docgenInfo = ${JSON.stringify(docgenInfo, null, 2)} as const;`,
21 | `export default docgenInfo;`,
22 | ].join("\n") + "\n",
23 | )
24 |
25 | console.error(`Docgen updated ${writePath}`)
26 | }
27 |
--------------------------------------------------------------------------------
/tests/frameworks/vite/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vite",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "mafs": "file:../mafs.tgz",
14 | "react": "^18.3.1",
15 | "react-dom": "^18.3.1"
16 | },
17 | "devDependencies": {
18 | "@eslint/js": "^9.23.0",
19 | "@types/react": "^18.3.20",
20 | "@types/react-dom": "^18.3.5",
21 | "@vitejs/plugin-react": "^4.3.4",
22 | "eslint": "^9.23.0",
23 | "eslint-plugin-react-hooks": "5.1.0-rc-fb9a90fa48-20240614",
24 | "eslint-plugin-react-refresh": "^0.4.19",
25 | "globals": "^15.15.0",
26 | "typescript": "^5.8.2",
27 | "typescript-eslint": "^8.28.0",
28 | "vite": "^5.4.15"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/e2e/movable-point.spec.tsx:
--------------------------------------------------------------------------------
1 | import { expect, test } from "@playwright/experimental-ct-react"
2 | import { MafsDragging } from "./components/MafsDragging"
3 |
4 | test("Moving MovablePoints", async ({ mount, page }) => {
5 | const component = await mount( )
6 | await page.waitForSelector(".mafs-movable-point")
7 |
8 | const points = await component.locator(".mafs-movable-point").all()
9 | expect(points.length).toBe(3)
10 | const [point1, point2, point3] = points
11 |
12 | await expect(page).toHaveScreenshot("before-moving.png")
13 |
14 | await point1.focus()
15 | await page.keyboard.press("ArrowRight")
16 | await point2.focus()
17 | await page.keyboard.press("Alt+ArrowRight")
18 | await point3.focus()
19 | await page.keyboard.press("Shift+ArrowRight")
20 |
21 | await point3.blur()
22 | await expect(page).toHaveScreenshot("after-moving.png")
23 | })
24 |
--------------------------------------------------------------------------------
/docs/app/guides/display/vectors/page.tsx:
--------------------------------------------------------------------------------
1 | import { PropTable } from "components/PropTable"
2 |
3 | import CodeAndExample from "components/CodeAndExample"
4 | import VectorExample from "guide-examples/display/vectors/VectorExample"
5 | import type { Metadata } from "next"
6 |
7 | export const metadata: Metadata = {
8 | title: "Vectors",
9 | }
10 |
11 | function Vectors() {
12 | return (
13 | <>
14 | Vectors are a handy line-and-arrow shape for visualizing direction and magnitude.
15 |
16 |
17 | Mafs ships with a small selection of common linear algebra functions (for both vectors and
18 | matrices), exposing them as vec. Those utilities are used extensively here.
19 |
20 |
21 |
22 |
23 |
24 | >
25 | )
26 | }
27 |
28 | export default Vectors
29 |
--------------------------------------------------------------------------------
/tests/frameworks/setup.ts:
--------------------------------------------------------------------------------
1 | import { execSync } from "node:child_process"
2 | import path from "node:path"
3 | import fs from "fs-extra"
4 |
5 | const ROOT = path.join(import.meta.dirname, "../..")
6 |
7 | async function main() {
8 | execSync("git clean -fdx .", { cwd: path.join(ROOT, "tests/frameworks"), stdio: "inherit" })
9 | execSync("pnpm build", { cwd: ROOT, stdio: "inherit" })
10 | const tarball = execSync("npm pack", { cwd: ROOT, stdio: "pipe" })
11 | fs.moveSync(
12 | path.join(ROOT, tarball.toString().trim()),
13 | path.join(ROOT, "tests/frameworks/mafs.tgz"),
14 | { overwrite: true },
15 | )
16 | execSync("pnpm install", { cwd: path.join(ROOT, "tests/frameworks"), stdio: "inherit" })
17 | console.error(`Wrote ${path.join(ROOT, "tests/frameworks/mafs.tgz")} and installed`)
18 | }
19 |
20 | main().catch((error) => {
21 | console.error(error)
22 | process.exit(1)
23 | })
24 |
--------------------------------------------------------------------------------
/docs/components/guide-examples/display/PointsAlongFunction.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | // prettier-ignore
4 | import { Mafs, Plot, Point, Coordinates, useMovablePoint } from "mafs"
5 | import range from "lodash/range"
6 |
7 | export default function PointsAlongFunction() {
8 | const fn = (x: number) => (x / 2) ** 2
9 | const sep = useMovablePoint([1, 0], {
10 | constrain: "horizontal",
11 | })
12 |
13 | const n = 10
14 |
15 | const points =
16 | sep.x != 0
17 | ? range(-n * sep.x, (n + 0.5) * sep.x, sep.x)
18 | : []
19 |
20 | return (
21 |
25 |
26 |
27 |
28 | {points.map((x, index) => (
29 |
30 | ))}
31 | {sep.element}
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "@typescript-eslint/parser",
4 | "plugins": ["@typescript-eslint"],
5 | "extends": [
6 | "eslint:recommended",
7 | "plugin:@typescript-eslint/recommended",
8 | "plugin:react/recommended",
9 | "plugin:react/jsx-runtime",
10 | "plugin:react-hooks/recommended",
11 | "prettier"
12 | ],
13 | "rules": {
14 | "react/prop-types": "off",
15 | "react/no-unescaped-entities": "off",
16 | "no-console": "error"
17 | },
18 | "overrides": [
19 | {
20 | "files": ["docs/**/*.tsx"],
21 | "rules": {
22 | "@typescript-eslint/explicit-module-boundary-types": "off"
23 | }
24 | },
25 | {
26 | "files": ["scripts/**/*", "tests/**/*"],
27 | "rules": {
28 | "no-console": "off"
29 | }
30 | }
31 | ],
32 | "settings": {
33 | "react": {
34 | "version": "detect"
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/docs/components/guide-examples/examples/FancyParabola.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Coordinates,
5 | Plot,
6 | Mafs,
7 | Transform,
8 | useMovablePoint,
9 | } from "mafs"
10 |
11 | export default function FancyParabola() {
12 | const a = useMovablePoint([-1, 0], {
13 | constrain: "horizontal",
14 | })
15 | const b = useMovablePoint([1, 0], {
16 | constrain: "horizontal",
17 | })
18 |
19 | const k = useMovablePoint([0, -1], {
20 | constrain: "vertical",
21 | })
22 |
23 | const mid = (a.x + b.x) / 2
24 | const fn = (x: number) => (x - a.x) * (x - b.x)
25 |
26 | return (
27 |
28 |
29 |
30 | (k.y * fn(x)) / fn(mid)} />
31 | {a.element}
32 | {b.element}
33 |
34 | {k.element}
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/docs/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | transpilePackages: ["mafs"],
4 |
5 | webpack: (config) => {
6 | config.module.rules.push({
7 | test: /\.tsx?$/,
8 | include: /guide-examples/,
9 | use: require.resolve("./guide-example-loader.mjs"),
10 | })
11 |
12 | return config
13 | },
14 | async redirects() {
15 | return [
16 | {
17 | source: "/guides/display/graphs",
18 | destination: "/guides/display/plots",
19 | permanent: false,
20 | },
21 | {
22 | source: "/guides/display/vector-fields",
23 | destination: "/guides/display/plots",
24 | permanent: false,
25 | },
26 | {
27 | source: "/guides/utility/transform",
28 | destination: "/guides/display/transform",
29 | permanent: false,
30 | },
31 | ]
32 | },
33 | }
34 |
35 | module.exports = nextConfig
36 |
--------------------------------------------------------------------------------
/src/display/Point.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { useTransformContext } from "../context/TransformContext"
3 | import { Theme } from "./Theme"
4 | import { vec } from "../vec"
5 |
6 | export interface PointProps {
7 | x: number
8 | y: number
9 | color?: string
10 | opacity?: number
11 | svgCircleProps?: React.SVGProps
12 | }
13 |
14 | export function Point({
15 | x,
16 | y,
17 | color = Theme.foreground,
18 | opacity = 1,
19 | svgCircleProps = {},
20 | }: PointProps) {
21 | const { viewTransform: pixelMatrix, userTransform: transform } = useTransformContext()
22 |
23 | const [cx, cy] = vec.transform([x, y], vec.matrixMult(pixelMatrix, transform))
24 |
25 | return (
26 |
33 | )
34 | }
35 |
36 | Point.displayName = "Point"
37 |
--------------------------------------------------------------------------------
/scripts/publish-branch.ts:
--------------------------------------------------------------------------------
1 | import childProcess from "node:child_process"
2 | import fs from "node:fs"
3 |
4 | const shortCommit = childProcess.execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim()
5 | const branch = childProcess.execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8" }).trim()
6 |
7 | const tag = branch === "main" ? "canary" : "experimental"
8 | const version = `0.0.0-${shortCommit}`
9 |
10 | console.log(`Publishing ${version} to npm...`)
11 |
12 | const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"))
13 | packageJson.version = version
14 | fs.writeFileSync("package.json", JSON.stringify(packageJson, null, 2))
15 |
16 | fs.writeFileSync(".npmrc", "//registry.npmjs.org/:_authToken=${NPM_TOKEN}")
17 |
18 | childProcess.execSync("pnpm build", { stdio: "inherit" })
19 | childProcess.execSync(`npm publish --ignore-scripts --access public --tag ${tag}`, {
20 | stdio: "inherit",
21 | env: { ...process.env },
22 | })
23 |
--------------------------------------------------------------------------------
/docs/app/guides/display/ellipses/page.tsx:
--------------------------------------------------------------------------------
1 | import CodeAndExample from "components/CodeAndExample"
2 |
3 | import MovableEllipse from "guide-examples/MovableEllipse"
4 | import WIP from "components/WIP"
5 | import Link from "next/link"
6 |
7 | import { PropTable } from "components/PropTable"
8 | import type { Metadata } from "next"
9 |
10 | export const metadata: Metadata = {
11 | title: "Ellipses",
12 | }
13 |
14 | export default function Page() {
15 | return (
16 | <>
17 | Ellipses take a center vector, radius vector, and an angle.
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | Support for defining ellipses in terms of two foci is planned. In the meantime, you can
26 | accomplish this using a{" "}
27 | parametric function.
28 |
29 |
30 | >
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/api-extractor.json:
--------------------------------------------------------------------------------
1 | {
2 | "mainEntryPointFilePath": "/build/types/src/index.d.ts",
3 | "bundledPackages": [],
4 | "apiReport": {
5 | "enabled": true,
6 | "reportFileName": "mafs.api.md",
7 | "reportFolder": ".api-report",
8 | "reportTempFolder": ".api-report/temp"
9 | },
10 | "dtsRollup": {
11 | "enabled": true,
12 | "untrimmedFilePath": "/build/index.d.ts"
13 | },
14 | "docModel": {
15 | "enabled": false
16 | },
17 | "tsdocMetadata": {
18 | "enabled": false
19 | },
20 | "messages": {
21 | "extractorMessageReporting": {
22 | "default": {
23 | "logLevel": "warning"
24 | },
25 | "ae-missing-release-tag": {
26 | "logLevel": "none"
27 | }
28 | },
29 | "tsdocMessageReporting": {
30 | "default": {
31 | "logLevel": "warning"
32 | }
33 | }
34 | },
35 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json"
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2021 Steven Petryk
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/docs/app/guides/examples/riemann-sums/page.tsx:
--------------------------------------------------------------------------------
1 | import CodeAndExample from "components/CodeAndExample"
2 | import Riemann from "guide-examples/examples/Riemann"
3 | import type { Metadata } from "next"
4 |
5 | export const metadata: Metadata = {
6 | title: "Riemann sums",
7 | }
8 |
9 | function RiemannPage() {
10 | return (
11 | <>
12 |
13 | This is one of the more complex examples. It draws Riemann partitions from point a {" "}
14 | to point b . While computing the partitions, their areas are summed up to show how
15 | the Riemann approximation compares to the true area under the given curve.
16 |
17 |
18 |
19 | In this example, some extra markup is used outside of Mafs to provide some inputs into the
20 | Mafs visualization. This is common, and recommended! Movable points are not the only way to
21 | provide inputs to Mafs.
22 |
23 |
24 |
25 | >
26 | )
27 | }
28 |
29 | export default RiemannPage
30 |
--------------------------------------------------------------------------------
/src/display/Plot/OfX.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { usePaneContext } from "../../context/PaneContext"
3 | import { Parametric, ParametricProps } from "./Parametric"
4 | import { vec } from "../../vec"
5 |
6 | export interface OfXProps extends Omit {
7 | y: (x: number) => number
8 | domain?: vec.Vector2
9 | svgPathProps?: React.SVGProps
10 | }
11 |
12 | export function OfX({ y, domain, ...props }: OfXProps) {
13 | const [xuMin, xuMax] = domain ?? [-Infinity, Infinity]
14 | const {
15 | xPaneRange: [xpMin, xpMax],
16 | } = usePaneContext()
17 | // Determine the most restrictive range values (either user-provided or the pane context)
18 | const xMin = Math.max(xuMin, xpMin)
19 | const xMax = Math.min(xuMax, xpMax)
20 |
21 | const xy = React.useCallback((x) => [x, y(x)], [y])
22 | const parametricDomain = React.useMemo(() => [xMin, xMax], [xMin, xMax])
23 |
24 | return
25 | }
26 |
27 | OfX.displayName = "Plot.OfX"
28 |
--------------------------------------------------------------------------------
/src/display/Plot/OfY.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { usePaneContext } from "../../context/PaneContext"
3 | import { Parametric, ParametricProps } from "./Parametric"
4 | import { vec } from "../../vec"
5 |
6 | export interface OfYProps extends Omit {
7 | x: (y: number) => number
8 | domain?: vec.Vector2
9 | svgPathProps?: React.SVGProps
10 | }
11 |
12 | export function OfY({ x, domain, ...props }: OfYProps) {
13 | const [yuMin, yuMax] = domain ?? [-Infinity, Infinity]
14 | const {
15 | yPaneRange: [ypMin, ypMax],
16 | } = usePaneContext()
17 | // Determine the most restrictive range values (either user-provided or the pane context)
18 | const yMin = Math.max(yuMin, ypMin)
19 | const yMax = Math.min(yuMax, ypMax)
20 |
21 | const xy = React.useCallback((y) => [x(y), y], [x])
22 | const parametricDomain = React.useMemo(() => [yMin, yMax], [yMin, yMax])
23 |
24 | return
25 | }
26 |
27 | OfY.displayName = "Plot.OfY"
28 |
--------------------------------------------------------------------------------
/docs/app/guides/experimental/animation/page.tsx:
--------------------------------------------------------------------------------
1 | import WIP from "components/WIP"
2 | import CodeAndExample from "components/CodeAndExample"
3 |
4 | import AnimatedPoint from "guide-examples/animation/AnimatedPoint"
5 | import type { Metadata } from "next"
6 |
7 | export const metadata: Metadata = {
8 | title: "Animation",
9 | }
10 |
11 | function Stopwatch() {
12 | return (
13 | <>
14 |
15 | useStopwatch allows you to start and stop a real-time clock for doing neat
16 | things like physics simulations.
17 |
18 |
19 |
20 | Pass startTime (defaults to 0) or endTime (defaults to Infinity)
21 | to constrain the stopwatch.
22 |
23 |
24 |
25 |
26 | Animation is quite underdeveloped in this library both from a performance and feature
27 | standpoint. It could use things like keyframing, easing, and sequencing.
28 |
29 |
30 |
31 |
32 | >
33 | )
34 | }
35 |
36 | export default Stopwatch
37 |
--------------------------------------------------------------------------------
/src/gestures/useWheelEnabler.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | /**
3 | * A custom hook that makes the `wheel` event not interrupt scrolling. It will
4 | * only allow the Mafs viewport to be zoomed using the wheel if the user hasn't
5 | * scrolled the page for 500ms, or if they are hovering over the Mafs viewport.
6 | */
7 | export function useWheelEnabler(zoomEnabled: boolean) {
8 | const [wheelEnabled, setWheelEnabled] = React.useState(false)
9 |
10 | const timer = React.useRef(0)
11 |
12 | React.useEffect(() => {
13 | if (!zoomEnabled) return
14 |
15 | function handleWindowScroll() {
16 | setWheelEnabled(false)
17 |
18 | clearTimeout(timer.current)
19 | timer.current = setTimeout(() => {
20 | setWheelEnabled(true)
21 | }, 500) as unknown as number
22 | }
23 |
24 | window.addEventListener("scroll", handleWindowScroll)
25 | return () => window.removeEventListener("scroll", handleWindowScroll)
26 | }, [zoomEnabled])
27 |
28 | return {
29 | wheelEnabled: zoomEnabled ? wheelEnabled : false,
30 | handleMouseMove() {
31 | setWheelEnabled(true)
32 | },
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mafs-docs",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@radix-ui/react-icons": "^1.3.0",
13 | "@stackblitz/sdk": "^1.11.0",
14 | "@types/node": "^18.19.57",
15 | "@types/react": "18.2.18",
16 | "@types/react-dom": "18.2.7",
17 | "@vercel/analytics": "^1.3.1",
18 | "endent": "^2.1.0",
19 | "hast-to-hyperscript": "^10.0.3",
20 | "js-easing-functions": "^1.0.3",
21 | "lodash": "^4.17.21",
22 | "mafs": "link:../src",
23 | "next": "^14.2.15",
24 | "react": "18.2.0",
25 | "react-dom": "18.2.0",
26 | "react-markdown": "^8.0.7",
27 | "refractor": "^4.8.1",
28 | "typescript": "4.9.4"
29 | },
30 | "devDependencies": {
31 | "@types/lodash": "^4.17.12",
32 | "@wooorm/starry-night": "^3.5.0",
33 | "autoprefixer": "^10.4.20",
34 | "hast-util-to-jsx-runtime": "^2.3.2",
35 | "postcss": "^8.4.47",
36 | "raw-loader": "^4.0.2",
37 | "react-docgen-typescript": "2.2.2",
38 | "tailwindcss": "^3.4.14",
39 | "webpack": "^5.95.0"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/docs/components/guide-examples/display/DynamicMovablePoints.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | // prettier-ignore
4 | import { Mafs, Coordinates, MovablePoint, useMovablePoint, Line, Theme, vec } from "mafs"
5 | import range from "lodash/range"
6 |
7 | export default function DynamicMovablePoints() {
8 | const start = useMovablePoint([-3, -1])
9 | const end = useMovablePoint([3, 1])
10 |
11 | function shift(shiftBy: vec.Vector2) {
12 | start.setPoint(vec.add(start.point, shiftBy))
13 | end.setPoint(vec.add(end.point, shiftBy))
14 | }
15 |
16 | const length = vec.dist(start.point, end.point)
17 | const betweenPoints = range(1, length - 0.5, 1).map((t) =>
18 | vec.lerp(start.point, end.point, t / length),
19 | )
20 |
21 | return (
22 |
23 |
24 |
25 |
29 |
30 | {start.element}
31 | {betweenPoints.map((point, i) => (
32 | {
37 | shift(vec.sub(newPoint, point))
38 | }}
39 | />
40 | ))}
41 | {end.element}
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/docs/components/guide-examples/display/images/ImageAnchorExample.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Coordinates,
5 | Image,
6 | Mafs,
7 | useMovablePoint,
8 | } from "mafs"
9 |
10 | import image from "./mafs.png"
11 |
12 | export default function VectorExample() {
13 | const center = useMovablePoint([2, 2])
14 | return (
15 |
16 |
17 |
25 |
33 |
41 |
49 | {center.element}
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/src/display/Line/Segment.tsx:
--------------------------------------------------------------------------------
1 | import { Stroked, Theme } from "../../display/Theme"
2 | import { useTransformContext } from "../../context/TransformContext"
3 | import { round } from "../../math"
4 | import { vec } from "../../vec"
5 |
6 | export interface SegmentProps extends Stroked {
7 | point1: vec.Vector2
8 | point2: vec.Vector2
9 | }
10 |
11 | export function Segment({
12 | point1,
13 | point2,
14 | color = Theme.foreground,
15 | style = "solid",
16 | weight = 2,
17 | opacity = 1.0,
18 | }: SegmentProps) {
19 | const { viewTransform: pixelMatrix, userTransform } = useTransformContext()
20 | const transform = vec.matrixMult(pixelMatrix, userTransform)
21 |
22 | const scaledPoint1 = vec.transform(point1, transform)
23 | const scaledPoint2 = vec.transform(point2, transform)
24 |
25 | return (
26 |
38 | )
39 | }
40 |
41 | Segment.displayName = "Line.Segment"
42 |
--------------------------------------------------------------------------------
/docs/app/guides/link.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Link from "next/link"
4 | import { useSelectedLayoutSegments } from "next/navigation"
5 |
6 | type GuideLinkProps = React.PropsWithChildren<{
7 | sectionTitle: string
8 | guideTitle: string
9 | guideSlug: string
10 | }>
11 |
12 | export function GuideLink({ sectionTitle, guideSlug, children }: GuideLinkProps) {
13 | const segments = useSelectedLayoutSegments()
14 | const active = segments[0] === sectionTitle && segments[1] === guideSlug
15 |
16 | return (
17 |
18 |
34 | {children}
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/docs/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/docs/components/guide-examples/utility/SimpleTransform.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | // prettier-ignore
4 | import { Mafs, Coordinates, Transform, useMovablePoint, Theme, Text, Polygon, Circle, vec, } from "mafs"
5 |
6 | export default function SimpleTransformExample() {
7 | const t = useMovablePoint([-4, -2])
8 | const s = useMovablePoint([8, 4], { color: Theme.blue })
9 | const r = useMovablePoint([1, 0], {
10 | color: Theme.green,
11 | constrain: (p) => vec.normalize(p),
12 | })
13 | const angle = Math.atan2(r.point[1], r.point[0])
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {s.element}
26 |
27 |
28 | {r.element}
29 |
30 |
31 | {t.element}
32 |
33 | )
34 | }
35 |
36 | function HelloBox() {
37 | return (
38 | <>
39 | {/* prettier-ignore */}
40 |
41 |
42 |
43 | Hello world!
44 |
45 | >
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/docs/app/guides/layout.tsx:
--------------------------------------------------------------------------------
1 | import GuidesSidebar from "./sidebar"
2 | import ScrollTop from "components/ScrollTop"
3 | import { Title } from "./title"
4 | import { NextPrevButtons } from "./next-prev-buttons"
5 | import type { Metadata } from "next"
6 |
7 | export const metadata: Metadata = {
8 | title: {
9 | template: "%s | Guides | Mafs",
10 | absolute: "Guides | Mafs",
11 | },
12 | }
13 |
14 | export default function GuidesLayout({ children }: { children: React.ReactNode }) {
15 | return (
16 | <>
17 |
18 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | {children}
38 |
39 |
40 |
41 |
42 |
43 |
44 | >
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/src/interaction/MovablePoint.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Theme } from "../display/Theme"
3 | import { vec } from "../vec"
4 | import { useMovable } from "./useMovable"
5 | import { MovablePointDisplay } from "../display/MovablePointDisplay"
6 |
7 | export type ConstraintFunction = (position: vec.Vector2) => vec.Vector2
8 |
9 | export interface MovablePointProps {
10 | /** The current position `[x, y]` of the point. */
11 | point: vec.Vector2
12 | /** A callback that is called as the user moves the point. */
13 | onMove: (point: vec.Vector2) => void
14 | /**
15 | * Constrain the point to only horizontal movement, vertical movement, or mapped movement.
16 | *
17 | * In mapped movement mode, you must provide a function that maps the user's mouse position
18 | * `[x, y]` to the position the point should "snap" to.
19 | */
20 | constrain?: ConstraintFunction
21 | color?: string
22 | }
23 |
24 | export function MovablePoint({
25 | point,
26 | onMove,
27 | constrain = (point) => point,
28 | color = Theme.pink,
29 | }: MovablePointProps) {
30 | const ref = React.useRef(null)
31 |
32 | const { dragging } = useMovable({ gestureTarget: ref, onMove, point, constrain })
33 |
34 | return
35 | }
36 |
37 | MovablePoint.displayName = "MovablePoint"
38 |
--------------------------------------------------------------------------------
/docs/app/guides/display/lines/page.tsx:
--------------------------------------------------------------------------------
1 | import CodeAndExample from "components/CodeAndExample"
2 | import { PropTable } from "components/PropTable"
3 |
4 | import LineSegmentExample from "guide-examples/LineSegmentExample"
5 | import LineThroughPointsExample from "guide-examples/LineThroughPointsExample"
6 | import LinePointSlopeExample from "guide-examples/LinePointSlopeExample"
7 | import LinePointAngleExample from "guide-examples/LinePointAngleExample"
8 | import type { Metadata } from "next"
9 |
10 | export const metadata: Metadata = {
11 | title: "Lines",
12 | }
13 |
14 | function Lines() {
15 | return (
16 | <>
17 | There are a few components for lines, depending on how you want to construct them.
18 |
19 | Line segment
20 |
21 |
22 |
23 |
24 | Line through two points
25 |
26 |
27 |
28 |
29 | Point and slope
30 |
31 |
32 |
33 |
34 | Point and angle
35 |
36 |
37 |
38 | >
39 | )
40 | }
41 |
42 | export default Lines
43 |
--------------------------------------------------------------------------------
/src/gestures/useCamera.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { clamp } from "../math"
3 | import { vec } from "../vec"
4 |
5 | export function useCamera({ minZoom, maxZoom }: { minZoom: number; maxZoom: number }) {
6 | const [matrix, setMatrix] = React.useState(vec.identity)
7 | const initialMatrix = React.useRef(vec.identity)
8 |
9 | return {
10 | matrix: matrix,
11 | setBase() {
12 | initialMatrix.current = matrix
13 | },
14 | move({ zoom, pan }: { zoom?: { at: vec.Vector2; scale?: number }; pan?: vec.Vector2 }) {
15 | const scale = 1 / (zoom?.scale ?? 1)
16 | const zoomAt = zoom?.at ?? [0, 0]
17 |
18 | const currentScale = initialMatrix.current[0]
19 | const minScale = 1 / maxZoom / currentScale
20 | const maxScale = 1 / minZoom / currentScale
21 |
22 | /**
23 | * Represents the amount of scaling to apply such that we never exceed the
24 | * minimum or maximum zoom level.
25 | */
26 | const clampedScale = clamp(scale, minScale, maxScale)
27 |
28 | const newCamera = vec
29 | .matrixBuilder(initialMatrix.current)
30 | .translate(...vec.scale(zoomAt, -1))
31 | .scale(clampedScale, clampedScale)
32 | .translate(...vec.scale(zoomAt, 1))
33 | .translate(...(pan ?? [0, 0]))
34 | .get()
35 |
36 | setMatrix(newCamera)
37 | },
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Mafs
2 |
3 | Mafs is a set of opinionated React components for creating math visualizations.
4 |
5 | [Visit the docs →](https://mafs.dev)
6 |
7 | ---
8 |
9 | ## Development
10 |
11 | Development is done inside of the Next.js documentation site, which directly
12 | imports Mafs components from `src/`. To start the development server, run:
13 |
14 | ```
15 | pnpm install
16 | pnpm start
17 | ```
18 |
19 | The version of `pnpm` in use can be viewed by checking the `packageManager`
20 | field of `package.json`. Using `corepack` to install pnpm will keep this
21 | up-to-date automatically. Mafs pins pnpm to a precise version to avoid spurious
22 | pnpm-lock.yaml changes.
23 |
24 | Then visit [localhost:3000](http://localhost:3000).
25 |
26 | ## Tests
27 |
28 | Mafs uses unit, end-to-end, and visual regression testing to ensure consistency between updates. It takes literal screenshots of components as rendered by the browser, and compares them to a known "correct" screenshot. Two of the browsers may require a Mac to run (Safari and iOS Safari).
29 |
30 | All examples on the documentation site are visually tested automatically—the test file is autogenerated.
31 |
32 | ```bash
33 | pnpm test # run both suites
34 | pnpm test:unit # to run just the Jest tests
35 | pnpm test:e2e # to run Playwright (end-to-end and visual tests)
36 | pnpm test -- --update-snapshots # to update the visual test baselines
37 | ```
38 |
--------------------------------------------------------------------------------
/src/context/TransformContext.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import invariant from "tiny-invariant"
3 | import { vec } from "../vec"
4 |
5 | interface TransformContextShape {
6 | /**
7 | * The resulting transformation matrix from any user-provided transforms (via
8 | * the ` ` component).
9 | */
10 | userTransform: vec.Matrix
11 |
12 | /**
13 | * A transformation that maps "math" space to pixel space. Note that, in many
14 | * cases, you don't need to use this transformation directly. Instead, use the
15 | * `var(--mafs-view-transform)` CSS custom property in combination with the
16 | * SVG `transform` prop.
17 | */
18 | viewTransform: vec.Matrix
19 | }
20 |
21 | export const TransformContext = React.createContext(null)
22 | TransformContext.displayName = "TransformContext"
23 |
24 | /**
25 | * A hook that returns the current transformation context. This is useful for
26 | * building custom Mafs components that need to be aware of how to map between
27 | * world space and pixel space, and also need to respond to user-provided
28 | * transformations.
29 | */
30 | export function useTransformContext() {
31 | const context = React.useContext(TransformContext)
32 |
33 | invariant(
34 | context,
35 | "TransformContext is not loaded. Are you rendering a Mafs component outside of a MafsView?",
36 | )
37 |
38 | return context
39 | }
40 |
--------------------------------------------------------------------------------
/src/debug/ViewportInfo.tsx:
--------------------------------------------------------------------------------
1 | import { useCoordinateContext } from "../context/CoordinateContext"
2 | import { usePaneContext } from "../context/PaneContext"
3 | import { useTransformContext } from "../context/TransformContext"
4 | import { vec } from ".."
5 |
6 | interface PaneVisualizerProps {
7 | /** The number of decimal places to which to round the displayed values. */
8 | precision?: number
9 | }
10 |
11 | export function ViewportInfo({ precision = 3 }: PaneVisualizerProps) {
12 | const { xMin, xMax, yMin, yMax } = useCoordinateContext()
13 | const { viewTransform } = useTransformContext()
14 | const { xPanes, yPanes } = usePaneContext()
15 |
16 | const [x, y] = vec.transform([xMin, yMin], viewTransform)
17 |
18 | const xPanesString = xPanes.map((pane) => `(${pane.join(", ")})`).join(" ")
19 | const yPanesString = yPanes.map((pane) => `(${pane.join(", ")})`).join(" ")
20 |
21 | return (
22 |
23 |
24 | x: ({xMin.toFixed(precision)}, {xMax.toFixed(precision)})
25 |
26 |
27 | y: ({yMin.toFixed(precision)}, {yMax.toFixed(precision)})
28 |
29 |
30 | xPanes: {xPanesString}
31 |
32 |
33 | yPanes: {yPanesString}
34 |
35 |
36 | )
37 | }
38 |
39 | ViewportInfo.displayName = "Debug.ViewportInfo"
40 |
--------------------------------------------------------------------------------
/docs/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link"
2 |
3 | import RiemannHomepage from "../components/RiemannHomepage"
4 |
5 | export default function Home() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 | React components for interactive math
14 |
15 |
16 | Build interactive, animated visualizations with declarative code.
17 |
18 |
19 |
20 |
21 |
25 | Get started →
26 |
27 |
28 |
29 |
30 |
31 |
37 |
38 |
39 |
40 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/docs/components/guide-examples/custom/pizza-slice.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Mafs, Coordinates, Debug } from "mafs"
4 | import * as React from "react"
5 | export default function Example() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | )
15 | }
16 |
17 | function PizzaSlice() {
18 | const maskId = `pizza-slice-mask-${React.useId()}`
19 |
20 | return (
21 |
26 |
27 |
28 | {/* prettier-ignore */}
29 |
30 |
31 |
32 |
33 | {/* prettier-ignore */}
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/src/display/Ellipse.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Filled, Theme } from "./Theme"
3 | import { useTransformContext } from "../context/TransformContext"
4 | import { vec } from "../vec"
5 |
6 | export interface EllipseProps extends Filled {
7 | center: vec.Vector2
8 | radius: vec.Vector2
9 | angle?: number
10 | svgEllipseProps?: React.SVGProps
11 | }
12 |
13 | export function Ellipse({
14 | center,
15 | radius,
16 | angle = 0,
17 | strokeStyle = "solid",
18 | strokeOpacity = 1.0,
19 | weight = 2,
20 | color = Theme.foreground,
21 | fillOpacity = 0.15,
22 | svgEllipseProps = {},
23 | }: EllipseProps) {
24 | const { viewTransform: toPx, userTransform } = useTransformContext()
25 |
26 | const transform = vec
27 | .matrixBuilder()
28 | .translate(...center)
29 | .mult(userTransform)
30 | .scale(1, -1)
31 | .mult(toPx)
32 | .scale(1, -1)
33 | .get()
34 |
35 | const cssTransform = `
36 | ${vec.toCSS(transform)}
37 | rotate(${angle * (180 / Math.PI)})
38 | `
39 |
40 | return (
41 |
60 | )
61 | }
62 |
63 | Ellipse.displayName = "Ellipse"
64 |
--------------------------------------------------------------------------------
/docs/app/guides/get-started/installation/page.tsx:
--------------------------------------------------------------------------------
1 | import Code from "components/Code"
2 | import CodeAndExample from "components/CodeAndExample"
3 |
4 | import Plain from "guide-examples/hello-fx/plain"
5 | import endent from "endent"
6 | import type { Metadata } from "next"
7 |
8 | export const metadata: Metadata = {
9 | title: "Installation",
10 | }
11 |
12 | function Page() {
13 | return (
14 | <>
15 | Install the package from NPM:
16 |
17 |
25 |
26 | And then make sure to load the stylesheet.
27 |
28 |
34 |
35 | Now, in your React app, you should be able to render a Mafs view.
36 |
37 |
38 |
39 | Fancy math font
40 |
41 |
42 | The font in use on this site (Computer Modern Serif) can be used with Mafs by importing{" "}
43 | mafs/font.css. It will import the appropriate font files and set Mafs'
44 | font-family.
45 |
46 |
47 |
54 |
55 |
56 | Computer Modern looks great, but it comes at a cost: using it will add about 220kB to your
57 | page load.
58 |
59 | >
60 | )
61 | }
62 |
63 | export default Page
64 |
--------------------------------------------------------------------------------
/docs/components/Code.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import endent from "endent"
5 | import { toH } from "hast-to-hyperscript"
6 |
7 | import { refractor } from "refractor"
8 | import bash from "refractor/lang/bash"
9 | import css from "refractor/lang/css"
10 | import diff from "refractor/lang/diff"
11 | import tsx from "refractor/lang/tsx"
12 |
13 | refractor.register(bash)
14 | refractor.register(css)
15 | refractor.register(diff)
16 | refractor.register(tsx)
17 |
18 | interface CodeProps {
19 | language: "tsx" | "css" | "bash" | "diff"
20 | source: string
21 | }
22 |
23 | function Code({ language, source }: CodeProps) {
24 | const codeRef = React.useRef(null)
25 |
26 | return (
27 |
28 |
29 | {language.toUpperCase()}
30 |
31 |
32 |
33 |
34 | {/* @ts-expect-error - `endent` has weird types but it works */}
35 |
36 |
37 |
38 |
39 |
40 | )
41 | }
42 |
43 | export default Code
44 |
45 | export function HighlightedCode({ source, language }: { language: string; source: string }) {
46 | const tree = refractor.highlight(source, language)
47 | // @ts-expect-error - idk
48 | const node = toH(React.createElement, tree)
49 |
50 | return node
51 | }
52 |
--------------------------------------------------------------------------------
/src/math.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from "vitest"
2 | import { clamp, range, round } from "./math"
3 |
4 | describe("clamp", () => {
5 | it("clamps a number between two values", () => {
6 | expect(clamp(0, 1, 2)).toBe(1)
7 | expect(clamp(10, 0, 1)).toBe(1)
8 | expect(clamp(-10, 0, 1)).toBe(0)
9 | })
10 | })
11 |
12 | describe("range", () => {
13 | it("generates a range", () => {
14 | expect(range(0, 10)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
15 | })
16 |
17 | it("generates a range with a step", () => {
18 | expect(range(0, 10, 2)).toEqual([0, 2, 4, 6, 8, 10])
19 | })
20 |
21 | it("handles when the step introduces weird floating point errors", () => {
22 | const problematicRange = range(0, 0.6, 0.2)
23 | expect(problematicRange[0]).toEqual(0)
24 | expect(problematicRange[1]).toBeCloseTo(0.2)
25 | expect(problematicRange[2]).toBeCloseTo(0.4)
26 | expect(problematicRange[3]).toEqual(0.6)
27 | })
28 | })
29 |
30 | describe("round", () => {
31 | it("rounds toward zero in the trivial case", () => {
32 | expect(round(0.2)).toEqual(0)
33 | expect(round(-0.2)).toEqual(-0)
34 |
35 | expect(round(0.12, 1)).toEqual(0.1)
36 | expect(round(-0.12, 1)).toEqual(-0.1)
37 | })
38 |
39 | it("rounds away from zero in the trivial case", () => {
40 | expect(round(0.8)).toEqual(1)
41 | expect(round(-0.8)).toEqual(-1)
42 |
43 | expect(round(0.08, 1)).toEqual(0.1)
44 | expect(round(-0.08, 1)).toEqual(-0.1)
45 | })
46 |
47 | it("rounds away from zero with 0.5, and toward zero with -0.5 (JS quirk, but acceptable for internal use)", () => {
48 | expect(round(0.5)).toEqual(1)
49 | expect(round(-0.5)).toEqual(-0)
50 | })
51 | })
52 |
--------------------------------------------------------------------------------
/src/display/PolyBase.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Filled, Theme } from "./Theme"
3 | import { vec } from "../vec"
4 | import { useTransformContext } from "../context/TransformContext"
5 |
6 | type SVGPolyProps = T extends "polygon"
7 | ? React.SVGProps
8 | : React.SVGProps
9 |
10 | export interface PolyBaseProps extends Filled {
11 | points: vec.Vector2[]
12 | }
13 |
14 | interface PolyBaseInternalProps extends PolyBaseProps {
15 | element: T
16 | svgPolyProps?: SVGPolyProps
17 | }
18 |
19 | export function PolyBase({
20 | element: PolyElement,
21 | points,
22 | color = Theme.foreground,
23 | weight = 2,
24 | fillOpacity = 0.15,
25 | strokeOpacity = 1.0,
26 | strokeStyle = "solid",
27 | svgPolyProps = {},
28 | }: PolyBaseInternalProps<"polygon"> | PolyBaseInternalProps<"polyline">) {
29 | const { userTransform } = useTransformContext()
30 |
31 | const scaledPoints = points
32 | .map((point) => vec.transform(point, userTransform).join(" "))
33 | .join(" ")
34 |
35 | return (
36 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/docs/components/guide-examples/custom/point-cloud.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Coordinates,
5 | Debug,
6 | Mafs,
7 | useTransformContext,
8 | vec,
9 | } from "mafs"
10 |
11 | export default function Example() {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | )
21 | }
22 |
23 | function PointCloud() {
24 | const { userTransform, viewTransform } =
25 | useTransformContext()
26 |
27 | const size = 5
28 | const perAxis = 10
29 |
30 | const points: { at: vec.Vector2; color: string }[] = []
31 | for (let i = 0; i <= size; i += size / perAxis) {
32 | for (let j = 0; j <= size; j += size / perAxis) {
33 | // prettier-ignore
34 | const userTransformedPoint = vec.transform([i, j], userTransform)
35 | // prettier-ignore
36 | const viewTransformedPoint = vec.transform(userTransformedPoint, viewTransform)
37 |
38 | const h = (360 * (i + j)) / (size * 2)
39 | const s = 100
40 |
41 | // If h is blueish, make the point lighter
42 | const l = h > 200 && h < 300 ? 70 : 50
43 |
44 | points.push({
45 | at: viewTransformedPoint,
46 | color: `hsl(${h} ${s}% ${l}%)`,
47 | })
48 | }
49 | }
50 |
51 | return (
52 | <>
53 | {points.map(({ at: [x, y], color }) => {
54 | return (
55 |
63 | )
64 | })}
65 | >
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "flake-utils": {
4 | "inputs": {
5 | "systems": "systems"
6 | },
7 | "locked": {
8 | "lastModified": 1731533236,
9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
10 | "owner": "numtide",
11 | "repo": "flake-utils",
12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
13 | "type": "github"
14 | },
15 | "original": {
16 | "owner": "numtide",
17 | "repo": "flake-utils",
18 | "type": "github"
19 | }
20 | },
21 | "nixpkgs": {
22 | "locked": {
23 | "lastModified": 1743259260,
24 | "narHash": "sha256-ArWLUgRm1tKHiqlhnymyVqi5kLNCK5ghvm06mfCl4QY=",
25 | "owner": "NixOS",
26 | "repo": "nixpkgs",
27 | "rev": "eb0e0f21f15c559d2ac7633dc81d079d1caf5f5f",
28 | "type": "github"
29 | },
30 | "original": {
31 | "owner": "NixOS",
32 | "ref": "nixpkgs-unstable",
33 | "repo": "nixpkgs",
34 | "type": "github"
35 | }
36 | },
37 | "root": {
38 | "inputs": {
39 | "flake-utils": "flake-utils",
40 | "nixpkgs": "nixpkgs"
41 | }
42 | },
43 | "systems": {
44 | "locked": {
45 | "lastModified": 1681028828,
46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
47 | "owner": "nix-systems",
48 | "repo": "default",
49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
50 | "type": "github"
51 | },
52 | "original": {
53 | "owner": "nix-systems",
54 | "repo": "default",
55 | "type": "github"
56 | }
57 | }
58 | },
59 | "root": "root",
60 | "version": 7
61 | }
62 |
--------------------------------------------------------------------------------
/src/debug/TransformWidget.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { vec } from "../vec"
3 | import { Theme } from "../display/Theme"
4 | import { useMovablePoint } from "../interaction/useMovablePoint"
5 | import { Transform } from "../display/Transform"
6 | import { Circle } from "../display/Circle"
7 | import { Polygon } from "../display/Polygon"
8 |
9 | export interface TransformWidgetProps {
10 | /** The components to transform */
11 | children: React.ReactNode
12 | }
13 |
14 | export function TransformWidget({ children }: TransformWidgetProps) {
15 | const t = useMovablePoint([0, 0])
16 | const s = useMovablePoint([1, 1], { color: Theme.blue })
17 | const r = useMovablePoint([1, 0], {
18 | color: Theme.green,
19 | constrain: (p) => vec.normalize(p),
20 | })
21 | const angle = Math.atan2(r.point[1], r.point[0])
22 |
23 | return (
24 | <>
25 |
26 |
27 |
28 | {children}
29 |
30 |
39 |
40 |
41 |
49 |
50 | {s.element}
51 |
52 |
53 | {r.element}
54 |
55 |
56 | {t.element}
57 | >
58 | )
59 | }
60 |
61 | TransformWidget.displayName = "Debug.TransformWidget"
62 |
--------------------------------------------------------------------------------
/src/display/LaTeX.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import katex, { KatexOptions } from "katex"
3 | import { vec } from "../vec"
4 | import { useTransformContext } from "../context/TransformContext"
5 | import { Theme } from "./Theme"
6 |
7 | interface LatexProps {
8 | tex: string
9 | at: vec.Vector2
10 | color?: string
11 | katexOptions?: KatexOptions
12 | }
13 |
14 | export function LaTeX({ at: center, tex, color = Theme.foreground, katexOptions }: LatexProps) {
15 | const ref = React.useRef(null)
16 | const { viewTransform, userTransform } = useTransformContext()
17 | const combinedTransform = vec.matrixMult(viewTransform, userTransform)
18 |
19 | // TODO: there's probably a better way to do this but we want to leave plenty
20 | // of room for the LaTeX to expand
21 | const width = 99999
22 | const height = 99999
23 |
24 | React.useEffect(() => {
25 | if (!ref.current) return
26 | katex.render(tex, ref.current, katexOptions)
27 | }, [katexOptions, tex])
28 |
29 | const pixelCenter = vec.add(vec.transform(center, combinedTransform), [-width / 2, -height / 2])
30 |
31 | return (
32 |
39 |
52 |
53 |
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/src/display/Image.tsx:
--------------------------------------------------------------------------------
1 | import { Anchor, computeAnchor } from "../math"
2 |
3 | export interface ImageProps {
4 | href: string
5 | x: number
6 | y: number
7 | /**
8 | * Indicate where, in the image (top, bottom, left, right, center), the x and
9 | * y coordinate refers to.
10 | */
11 | anchor?: Anchor
12 | width: number
13 | height: number
14 | /**
15 | * Whether to preserve the aspect ratio of the image. By default, the image
16 | * will be centered and scaled to fit the width and height. If you want to
17 | * squish the image to be the same shape as the box, set this to "none".
18 | *
19 | * This is passed directly to the `preserveAspectRatio` attribute of the SVG
20 | * `` element.
21 | *
22 | * See [preserveAspectRatio](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/preserveAspectRatio) on MDN.
23 | */
24 | preserveAspectRatio?: string
25 |
26 | svgImageProps?: React.SVGProps
27 | }
28 |
29 | export function Image({
30 | href,
31 | x,
32 | y,
33 | width,
34 | height,
35 | anchor = "bl",
36 | preserveAspectRatio,
37 | svgImageProps,
38 | }: ImageProps) {
39 | const [anchorX, anchorY] = computeAnchor(anchor, x, y, width, height)
40 |
41 | const transform = [
42 | "var(--mafs-view-transform)",
43 | "var(--mafs-user-transform)",
44 | // Ensure the image is not upside down (since Mafs has the y-axis pointing
45 | // up, while SVG has it pointing down).
46 | "scaleY(-1)",
47 | ].join(" ")
48 |
49 | return (
50 |
60 | )
61 | }
62 |
--------------------------------------------------------------------------------
/docs/components/guide-examples/display/Latex.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Mafs,
5 | LaTeX,
6 | Coordinates,
7 | useMovablePoint,
8 | Transform,
9 | } from "mafs"
10 |
11 | // \x is still a special case, even when using String.raw,
12 | // so we make a convenient pre-escaped string for it here.
13 | const x = "\\x"
14 |
15 | import { round } from "lodash"
16 |
17 | export default function LatexExample() {
18 | const l = useMovablePoint([-2, 1], {
19 | constrain: ([x, y]) => [round(x, 1), round(y, 1)],
20 | })
21 | const r = useMovablePoint([2, 1], {
22 | constrain: ([x, y]) => [round(x, 1), round(y, 1)],
23 | })
24 |
25 | const lx = l.x.toFixed(1)
26 | const ly = l.y.toFixed(1)
27 | const rx = r.x.toFixed(1)
28 | const ry = r.y.toFixed(1)
29 |
30 | return (
31 |
32 |
36 |
37 |
43 |
44 |
45 |
46 |
52 |
53 |
54 | {l.element}
55 | {r.element}
56 |
57 |
67 |
68 | )
69 | }
70 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "@playwright/experimental-ct-react"
2 | import { devices, ViewportSize } from "@playwright/test"
3 |
4 | const viewport: ViewportSize = { width: 500, height: 500 }
5 |
6 | export default defineConfig({
7 | timeout: 30 * 1000,
8 | expect: {
9 | timeout: 5000,
10 | toHaveScreenshot: {
11 | maxDiffPixelRatio: 0.02,
12 | },
13 | },
14 | fullyParallel: true,
15 | forbidOnly: !!process.env.CI,
16 | retries: process.env.CI ? 2 : 0,
17 | workers: undefined,
18 | reporter: "html",
19 | use: {
20 | actionTimeout: 0,
21 | trace: "on-first-retry",
22 |
23 | ctViteConfig: {
24 | resolve: {
25 | alias: {
26 | mafs: "src/index.tsx",
27 | },
28 | },
29 | },
30 | },
31 | projects: [
32 | { name: "chromium", use: { ...devices["Desktop Chrome"], viewport }, testDir: "./e2e" },
33 | { name: "firefox", use: { ...devices["Desktop Firefox"], viewport }, testDir: "./e2e" },
34 | { name: "webkit", use: { ...devices["Desktop Safari"], viewport }, testDir: "./e2e" },
35 | { name: "Mobile Chrome", use: { ...devices["Pixel 5"], viewport }, testDir: "./e2e" },
36 | { name: "Mobile Safari", use: { ...devices["iPhone 12"], viewport }, testDir: "./e2e" },
37 |
38 | {
39 | name: "frameworks-setup",
40 | use: { ...devices["Desktop Chrome"], viewport },
41 | testDir: "./tests",
42 | testMatch: "tests/frameworks/setup.ts",
43 | },
44 | {
45 | name: "frameworks",
46 | use: { ...devices["Desktop Chrome"], viewport },
47 | testDir: "./tests",
48 | testMatch: "tests/frameworks/*.spec.tsx",
49 | dependencies: ["frameworks-setup"],
50 | // Since these tests operate on the actual file system, parallelism messes them up.
51 | fullyParallel: false,
52 | },
53 | ],
54 | })
55 |
--------------------------------------------------------------------------------
/src/display/MovablePointDisplay.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { vec } from "../vec"
3 | import { useTransformContext } from "../context/TransformContext"
4 | import { Theme } from "./Theme"
5 |
6 | export interface MovablePointDisplayProps {
7 | color?: string
8 | ringRadiusPx?: number
9 | dragging: boolean
10 | point: vec.Vector2
11 | }
12 |
13 | export const MovablePointDisplay = React.forwardRef(
14 | (props: MovablePointDisplayProps, ref) => {
15 | const { color = Theme.pink, ringRadiusPx = 15, dragging, point } = props
16 |
17 | const { viewTransform, userTransform } = useTransformContext()
18 |
19 | const combinedTransform = React.useMemo(
20 | () => vec.matrixMult(viewTransform, userTransform),
21 | [viewTransform, userTransform],
22 | )
23 |
24 | const [xPx, yPx] = vec.transform(point, combinedTransform)
25 |
26 | return (
27 |
38 |
39 |
45 |
46 |
47 |
48 | )
49 | },
50 | )
51 |
52 | MovablePointDisplay.displayName = "MovablePointDisplay"
53 |
--------------------------------------------------------------------------------