diff --git a/docs/app/guides/display/scenes/page.tsx b/docs/app/guides/display/scenes/page.tsx
new file mode 100644
index 00000000..d1ad0729
--- /dev/null
+++ b/docs/app/guides/display/scenes/page.tsx
@@ -0,0 +1,32 @@
+import { PropTable } from "components/PropTable"
+import CodeAndExample from "components/CodeAndExample"
+
+import ScenesExample from "guide-examples/display/scenes/ScenesExample"
+import Code from "components/Code"
+
+import type { Metadata } from "next"
+
+export const metadata: Metadata = {
+ title: "Scenes",
+}
+
+function ScenesPage() {
+ return (
+ <>
+ {/*
+ Scenes are a way to create a new coordinate space and show it inside of Mafs. This can be
+ useful for doing something like showing two visualizations side-by-side.
+
+
+
+
+
Basic scene
*/}
+
+
+
+ {/* */}
+ >
+ )
+}
+
+export default ScenesPage
diff --git a/docs/app/guides/guides.tsx b/docs/app/guides/guides.tsx
index 5c47798e..75c12955 100644
--- a/docs/app/guides/guides.tsx
+++ b/docs/app/guides/guides.tsx
@@ -9,6 +9,8 @@ import {
RotateCounterClockwiseIcon,
TextIcon,
CursorArrowIcon,
+ ViewNoneIcon,
+ EnterFullScreenIcon,
PlayIcon,
} from "@radix-ui/react-icons"
@@ -21,6 +23,7 @@ import {
TransformContextsIcon,
DebugIcon,
LinearAlgebraIcon,
+ SceneIcon,
} from "components/icons"
type Section = {
@@ -53,6 +56,7 @@ export const Guides: Section[] = [
guides: [
{ title: "Mafs", icon: CardStackIcon, slug: "mafs" },
{ title: "Coordinates", icon: GridIcon, slug: "coordinates" },
+ { title: "Scenes", icon: SceneIcon, slug: "scenes" },
{ separator: true },
{ title: "Points", icon: DotFilledIcon, slug: "points" },
{ title: "Lines", icon: LinesIcon, slug: "lines" },
diff --git a/docs/components/guide-examples/display/scenes/ScenesExample.tsx b/docs/components/guide-examples/display/scenes/ScenesExample.tsx
new file mode 100644
index 00000000..3d0b395b
--- /dev/null
+++ b/docs/components/guide-examples/display/scenes/ScenesExample.tsx
@@ -0,0 +1,83 @@
+"use client"
+
+import { clamp } from "lodash"
+import {
+ Circle,
+ Coordinates,
+ Mafs,
+ Plot,
+ Scene,
+ Theme,
+ useMovablePoint,
+} from "mafs"
+
+function Scene1({ sceneSize, sceneSpacing }: any) {
+ const c = useMovablePoint([0, 0], {
+ constrain: ([x, y]) => [
+ clamp(x, -10, 10),
+ clamp(y, -10, 10),
+ ],
+ })
+
+ return (
+
+
+ Math.sin(x - c.x) + (x - c.x) / 2 + c.y}
+ color={Theme.blue}
+ />
+ {c.element}
+
+ )
+}
+
+function Scene2({ sceneSize, sceneSpacing }: any) {
+ return (
+
+
+
+
+ )
+}
+
+export default function Example() {
+ const sceneSize = 250
+ const sceneSpacing = 50
+
+ return (
+
+
+
+
+ )
+}
diff --git a/docs/components/icons.tsx b/docs/components/icons.tsx
index d94d364c..84e65061 100644
--- a/docs/components/icons.tsx
+++ b/docs/components/icons.tsx
@@ -284,3 +284,22 @@ export function LinearAlgebraIcon(props: React.SVGProps) {
)
}
+
+export function SceneIcon(props: React.SVGProps) {
+ return (
+
+ )
+}
diff --git a/src/display/Scene.tsx b/src/display/Scene.tsx
new file mode 100644
index 00000000..8d71c990
--- /dev/null
+++ b/src/display/Scene.tsx
@@ -0,0 +1,131 @@
+import * as React from "react"
+import CoordinateContext, { CoordinateContextShape } from "../context/CoordinateContext"
+import PaneManager from "../context/PaneContext"
+
+import { round } from "../math"
+import { vec } from "../vec"
+import { TransformContext } from "../context/TransformContext"
+import { SpanContext } from "../context/SpanContext"
+
+export type ScenePropsT = React.PropsWithChildren<{
+ width?: number | "auto"
+ height?: number
+
+ /** Whether to enable panning with the mouse and keyboard */
+ pan?: boolean
+
+ /**
+ * Whether to enable zooming with the mouse and keyboard. This can also be an
+ * object with `min` and `max` properties to set the scale limits.
+ *
+ * * `min` should be in the range (0, 1].
+ * * `max` should be in the range [1, ∞).
+ */
+ zoom?: boolean | { min: number; max: number }
+
+ /**
+ * A way to declare the "area of interest" of your visualizations. Mafs will center and zoom to
+ * this area.
+ */
+ viewBox?: { x?: vec.Vector2; y?: vec.Vector2; padding?: number }
+ /**
+ * Whether to squish the graph to fill the Mafs viewport or to preserve the aspect ratio of the
+ * coordinate space.
+ */
+ preserveAspectRatio?: "contain" | false
+
+ /** Called when the view is clicked on, and passed the point where it was clicked. */
+ onClick?: (point: vec.Vector2, event: MouseEvent) => void
+}>
+
+type SceneProps = {
+ width: number
+ height: number
+ x: number
+ y: number
+} & Required> &
+ Pick
+
+export function Scene({ x, y, width, height, viewBox, preserveAspectRatio, children }: SceneProps) {
+ const padding = viewBox?.padding ?? 0.5
+ // Default behavior for `preserveAspectRatio == false`
+ let xMin = (viewBox?.x?.[0] ?? 0) - padding
+ let xMax = (viewBox?.x?.[1] ?? 0) + padding
+ let yMin = (viewBox?.y?.[0] ?? 0) - padding
+ let yMax = (viewBox?.y?.[1] ?? 0) + padding
+
+ if (preserveAspectRatio === "contain") {
+ const aspect = width / height
+ const aoiAspect = (xMax - xMin) / (yMax - yMin)
+
+ if (aoiAspect > aspect) {
+ const yCenter = (yMax + yMin) / 2
+ const ySpan = (xMax - xMin) / aspect / 2
+ yMin = yCenter - ySpan
+ yMax = yCenter + ySpan
+ } else {
+ const xCenter = (xMax + xMin) / 2
+ const xSpan = ((yMax - yMin) * aspect) / 2
+ xMin = xCenter - xSpan
+ xMax = xCenter + xSpan
+ }
+ }
+
+ const xSpan = xMax - xMin
+ const ySpan = yMax - yMin
+
+ const viewTransform = React.useMemo(() => {
+ const scaleX = round((1 / xSpan) * width, 5)
+ const scaleY = round((-1 / ySpan) * height, 5)
+ return vec.matrixBuilder().scale(scaleX, scaleY).get()
+ }, [height, width, xSpan, ySpan])
+
+ const viewTransformCSS = vec.toCSS(viewTransform)
+
+ const coordinateContext = React.useMemo(
+ () => ({ xMin, xMax, yMin, yMax, height, width }),
+ [xMin, xMax, yMin, yMax, height, width],
+ )
+
+ const id = React.useId()
+
+ console.log({ xSpan, ySpan, viewTransformCSS, coordinateContext })
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ )
+}
+
+Scene.displayName = "Scene"
diff --git a/src/index.tsx b/src/index.tsx
index ddd6184c..547334e3 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -6,6 +6,8 @@ export type { MafsProps } from "./view/Mafs"
export { Coordinates } from "./display/Coordinates"
export { autoPi as labelPi } from "./display/Coordinates/Cartesian"
+export { Scene } from "./display/Scene"
+
export { Plot } from "./display/Plot"
export type { OfXProps, OfYProps, ParametricProps, VectorFieldProps } from "./display/Plot"