Skip to content

Commit

Permalink
Initial custom components API (#88)
Browse files Browse the repository at this point in the history
This PR exposes some basic custom components APIs:

- `usePaneContext` (undocumented)
- `Debug.TransformWidget` (half-undocumented)
- `useTransformContext`
- `var(--mafs-view-transform)`
- `var(--mafs-user-transform)`

More docs, and potentially some API changes, to come. Just wanted to get
this out early for folks to use.
  • Loading branch information
stevenpetryk authored Jan 16, 2023
1 parent b681c6c commit ec4cb37
Show file tree
Hide file tree
Showing 22 changed files with 573 additions and 30 deletions.
42 changes: 42 additions & 0 deletions .api-report/mafs.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export type ConstraintFunction = (position: Vector2) => Vector2;
// @public (undocumented)
export type _ConstraintFunction1 = (position: Vector2) => Vector2;

// @public (undocumented)
export const Debug: {
TransformWidget: typeof TransformWidget;
};

// @public
export function det(m: Matrix): number;

Expand Down Expand Up @@ -156,6 +161,9 @@ export function matrixInvert(matrix: Matrix): Matrix | null;
// @public
export function matrixMult(m: Matrix, m2: Matrix): Matrix;

// @public (undocumented)
export function matrixToCSSTransform(matrix: Matrix): string;

// @public
export function midpoint(v: Vector2, v2: Vector2): Vector2;

Expand Down Expand Up @@ -199,6 +207,18 @@ export interface OfYProps extends Omit<ParametricProps, "xy" | "t"> {
x: (y: number) => number;
}

// @public (undocumented)
export interface PaneContextShape {
// (undocumented)
xPaneRange: Interval;
// (undocumented)
xPanes: Interval[];
// (undocumented)
yPaneRange: Interval;
// (undocumented)
yPanes: Interval[];
}

// @public (undocumented)
export function Parametric({ xy, t, color, style, weight, opacity, maxSamplingDepth, minSamplingDepth, svgPathProps, }: ParametricProps): JSX.Element;

Expand Down Expand Up @@ -350,12 +370,21 @@ export interface ThroughPointsProps extends Stroked {
point2: Vector2;
}

// @public
export const toCSS: typeof matrixToCSSTransform;

// @public (undocumented)
export function Transform(props: TransformProps): JSX.Element;

// @public
export function transform(v: Vector2, m: Matrix): Vector2;

// @public (undocumented)
export interface TransformContextShape {
userTransform: Matrix;
viewTransform: Matrix;
}

// @public (undocumented)
export type TransformProps = React_2.PropsWithChildren<{
matrix?: Matrix;
Expand All @@ -365,6 +394,12 @@ export type TransformProps = React_2.PropsWithChildren<{
shear?: Vector2;
}>;

// @public (undocumented)
export function TransformWidget({ children }: TransformWidgetProps): JSX.Element;

// @public (undocumented)
export type TransformWidgetProps = React_2.PropsWithChildren<unknown>;

// @public (undocumented)
export interface UseMovablePoint {
// (undocumented)
Expand All @@ -389,9 +424,15 @@ export interface UseMovablePointArguments {
constrain?: "horizontal" | "vertical" | ConstraintFunction;
}

// @public (undocumented)
export function usePaneContext(): PaneContextShape;

// @public (undocumented)
export function useStopwatch(options?: StopwatchArguments): Stopwatch;

// @public
export function useTransformContext(): TransformContextShape;

// @public (undocumented)
export const vec: {
add: typeof add;
Expand All @@ -413,6 +454,7 @@ export const vec: {
det: typeof det;
matrixInvert: typeof matrixInvert;
matrixBuilder: typeof matrixBuilder;
toCSS: typeof matrixToCSSTransform;
identity: Matrix;
};

Expand Down
89 changes: 89 additions & 0 deletions docs/app/guides/custom-components/contexts/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"use client"

import PizzaSlice from "guide-examples/custom/pizza-slice"
import PizzaSliceSource from "!raw-loader!guide-examples/custom/pizza-slice"

import PointCloud from "guide-examples/custom/point-cloud"
import PointCloudSource from "!raw-loader!guide-examples/custom/point-cloud"

import CodeAndExample from "components/CodeAndExample"
import Link from "next/link"

export default function TransformContextsPage() {
return (
<>
<p>
At its core, Mafs is just SVG with two contextual transforms. Those transforms correspond to
two things:
</p>

<ul>
<li>
The <strong>view transform</strong>, which maps from world space to pixel space.
</li>
<li>
The <strong>user transform</strong>, which is imposed by the{" "}
<Link href="/guides/display/transform">Transform</Link> component.
</li>
</ul>

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

<p>Mafs provides these transforms through two means:</p>

<ul>
<li>
The <code>--mafs-view-transform</code> and <code>--mafs-user-transform</code> CSS custom
properties, which can be applied to an SVG element's <code>style</code> attribute.
</li>
<li>
The <code>useTransformContext</code> hook, which returns an object containing the{" "}
<code>viewTransform</code> matrix and the <code>userTransform</code> matrix.
</li>
</ul>

<p>
Components can mix and match these two approaches depending on needs. For example, the{" "}
<Link href="/guides/display/text">Text</Link> component transforms its <em>anchor point</em>{" "}
in JavaScript, and doesn't apply any CSS transforms, because that would distort the text
itself. On the other hand, the <Link href="/guides/display/ellipse">Ellipse</Link> component
almost entirely relies on CSS transforms internally.
</p>

<h2>Accessing transforms in CSS</h2>

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

<CodeAndExample component={<PizzaSlice />} source={PizzaSliceSource} />

<p>
This is an example of a component that gets entirely transformed by the user and view
transforms. The circle 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.
</p>

<h2>Accessing transforms in JavaScript</h2>

<p>
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 <em>pixels</em> (regardless
of the viewport or any transforms), we use the <code>useTransformContext</code> hook to get
the user and view transforms and apply them to the circles' <code>x</code> and{" "}
<code>y</code> 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.
</p>

<CodeAndExample component={<PointCloud />} source={PointCloudSource} />
</>
)
}
22 changes: 22 additions & 0 deletions docs/app/guides/custom-components/overview/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"use client"

import PizzaMarch from "guide-examples/custom/pizza-march"

export default function CustomPage() {
return (
<>
<p>
Sometimes, Mafs simply won't have the component you need. When that happens, Mafs provides
APIs to drop one level deeper, letting you render any SVG elements you want. All it takes is
some work to ensure things render correctly.
</p>

<p>
In learning this, we'll make a <code>PizzaSlice</code> component that behaves just like a
built-in Mafs component.
</p>

<PizzaMarch />
</>
)
}
25 changes: 23 additions & 2 deletions docs/app/guides/guides.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ import {
CursorArrowIcon,
PlayIcon,
} from "@radix-ui/react-icons"
import { FunctionIcon, EllipseIcon, LinesIcon } from "components/icons"

import {
FunctionIcon,
EllipseIcon,
LinesIcon,
CustomComponentsIcon,
TransformContextsIcon,
} from "components/icons"

type Section = {
title: string
Expand All @@ -20,6 +27,7 @@ type Section = {

type Guide = {
title: string
sidebarTitle?: string
slug: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon?: React.FunctionComponent<any>
Expand All @@ -30,7 +38,7 @@ type Separator = { separator: true }

export const Guides: Section[] = [
{
title: "Get Started",
title: "Get started",
guides: [
{ title: "Installation", slug: "installation" },
{ title: "Hello f(x)", slug: "hello-f-x" },
Expand Down Expand Up @@ -59,6 +67,19 @@ export const Guides: Section[] = [
title: "Interaction",
guides: [{ title: "Movable points", icon: CursorArrowIcon, slug: "movable-points" }],
},
{
title: "Custom components",
guides: [
{
sidebarTitle: "Overview",
title: "Custom elements",
icon: CustomComponentsIcon,
slug: "overview",
},
{ title: "Transform contexts", icon: TransformContextsIcon, slug: "contexts" },
// { title: "Panes", icon: MarginIcon, slug: "panes" },
],
},
{
title: "Animation",
guides: [{ title: "Stopwatch", icon: PlayIcon, slug: "stopwatch" }],
Expand Down
4 changes: 2 additions & 2 deletions docs/app/guides/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ type GuideLinkProps = React.PropsWithChildren<{
guideSlug: string
}>

export function GuideLink({ sectionTitle, guideTitle, guideSlug, children }: GuideLinkProps) {
export function GuideLink({ sectionTitle, guideSlug, children }: GuideLinkProps) {
const segments = useSelectedLayoutSegments()
const active = segments[0] === sectionTitle && segments[1] === guideSlug

return (
<li>
<Link
href={`/guides/${sectionTitle}/${guideTitle}`}
href={`/guides/${sectionTitle}/${guideSlug}`}
className={`
flex gap-2 items-center px-2 -mx-2 py-0.5 rounded-lg
md:hover:bg-slate-100 md:dark:hover:bg-slate-800
Expand Down
2 changes: 1 addition & 1 deletion docs/app/guides/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function GuidesSidebar() {
key={index}
>
{IconElement && <IconElement className="opacity-50" />}
{fancyFx(guide.title)}
{fancyFx(guide.sidebarTitle ?? guide.title)}
</GuideLink>
)
})}
Expand Down
96 changes: 96 additions & 0 deletions docs/components/guide-examples/custom/pizza-march.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { range } from "lodash"
import {
Mafs,
CartesianCoordinates,
Plot,
useMovablePoint,
Transform,
useTransformContext,
vec,
} from "mafs"

let inc = 0

function PizzaSlice({ at, radius: r }) {
const [x, y] = at
inc++

const { userTransform } = useTransformContext()

const userTransformCSS = vec.toCSS(userTransform)

return (
<g
style={{
transform: `var(--mafs-view-transform)`,
}}
>
<defs>
<mask
id={`pizza-slice-mask-${inc}`}
maskUnits="userSpaceOnUse"
>
<polyline
id="pizza-slice"
points={`0,0 ${r},${r / 2} ${r},${-r / 2}`}
fill="white"
stroke="none"
/>
</mask>
</defs>

<g
mask={`url(#pizza-slice-mask-${inc})`}
transform={`translate(${x}, ${y}) ${userTransformCSS}`}
maskContentUnits="userSpaceOnUse"
>
<circle cx={0} cy={0} r={r} fill="brown" />
<circle cx={0} cy={0} r={r * 0.85} fill="yellow" />
{/* prettier-ignore */}
<circle cx={0 + r * 0.4} cy={0 + r*0.1} r={r * 0.11} fill="red" />
{/* prettier-ignore */}
<circle cx={0 + r * 0.2} cy={0 - r*0.1} r={r * 0.09} fill="red" />
{/* prettier-ignore */}
<circle cx={0 + r * 0.5} cy={0 - r*0.15} r={r * 0.1} fill="red" />
{/* prettier-ignore */}
<circle cx={0 + r * 0.70} cy={0 + r*0.05} r={r * 0.11} fill="red" />
{/* prettier-ignore */}
<circle cx={0 + r * 0.65} cy={0 + r*0.35} r={r * 0.1} fill="red" />
{/* prettier-ignore */}
<circle cx={0 + r * 0.65} cy={0 - r*0.37} r={r * 0.08} fill="red" />
</g>
</g>
)
}

export default function Example() {
const fn = (x) => Math.sin(x * 2)
const deriv = (x) => 2 * Math.cos(x * 2)

const offset = useMovablePoint([2, fn(2)], {
constrain: ([x, y]) => [x, fn(x)],
})

const points = range(-4, 0.1, 1 / 2).map(
(x) => [x + offset.x, fn(x + offset.x)] as const
)

return (
<Mafs height={300} viewBox={{ y: [-1, 1] }}>
<CartesianCoordinates />
<Plot.OfX y={fn} />
{points.map((p, index) => (
<Transform
rotate={Math.PI + Math.atan(deriv(p[0]))}
key={index}
>
<PizzaSlice at={p} radius={0.5} />
</Transform>
))}

{offset.element}
</Mafs>
)
}
Loading

1 comment on commit ec4cb37

@vercel
Copy link

@vercel vercel bot commented on ec4cb37 Jan 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.