Skip to content

Commit

Permalink
Add support for zooming (#103)
Browse files Browse the repository at this point in the history
Closes #98.

Allows zooming via:

- Pinch on mobile devices (this was a tough one because it required
simultaneous zooming and panning, but only if `pan` hasn't been set to
false).
- Scrolling on desktop (but to avoid interrupting scrolling, there is a
500ms timeout where Mafs waits for scroll idle).
- Pinching on desktop (like with a trackpad).

This introduces a new matrix into Mafs' pile of matrices, `camera`,
which is used to transform `(xMin, yMin)` and `(xMax, yMax)`. This
counterintuitively means that translations and scaling are reversed—if
`camera` has a scale of 2, that's equivalent to being "zoomed 0.5x".
  • Loading branch information
stevenpetryk authored Jan 25, 2023
1 parent 10c1deb commit 7051e5a
Show file tree
Hide file tree
Showing 16 changed files with 344 additions and 82 deletions.
6 changes: 5 additions & 1 deletion .api-report/mafs.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export const Line: {
};

// @public (undocumented)
export function Mafs({ width: desiredWidth, height, pan, viewBox, preserveAspectRatio, children, ssr, }: MafsProps): JSX.Element;
export function Mafs({ width: desiredWidth, height, pan, zoom, viewBox, preserveAspectRatio, children, ssr, }: MafsProps): JSX.Element;

// @public (undocumented)
export namespace Mafs {
Expand All @@ -108,6 +108,10 @@ export type MafsProps = React_2.PropsWithChildren<{
width?: number | "auto";
height?: number;
pan?: boolean;
zoom?: boolean | {
min: number;
max: number;
};
viewBox?: {
x?: vec.Vector2;
y?: vec.Vector2;
Expand Down
28 changes: 28 additions & 0 deletions docs/app/guides/display/mafs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import PlainMafsExampleSource from "!raw-loader!guide-examples/PlainMafsExample"
import ContainViewboxExample from "guide-examples/display/viewbox/ContainViewbox"
import ContainViewboxExampleSource from "!raw-loader!guide-examples/display/viewbox/ContainViewbox"

import ZoomExample from "guide-examples/display/viewbox/ZoomExample"
import ZoomExampleSource from "!raw-loader!guide-examples/display/viewbox/ZoomExample"

import StretchViewboxExample from "guide-examples/display/viewbox/StretchViewbox"
import StretchViewboxExampleSource from "!raw-loader!guide-examples/display/viewbox/StretchViewbox"
import Code from "components/Code"
Expand All @@ -34,6 +37,31 @@ function MafsPage() {
.
</p>

<h2>Zooming and panning</h2>

<p>
Mafs can be zoomed and panned by end users using a variety of input methods. Zooming and
panning can be enabled, disabled, and configured via the <code>zoom</code> and{" "}
<code>pan</code> props.
</p>

<ul>
<li>The mouse wheel zooms the viewport.</li>
<li>Pressing and dragging pans the viewport.</li>
<li>The "pinch" gesture zooms and pans the viewport simultaneously.</li>
<li>
The arrow, <kbd>-</kbd>, and <kbd>+</kbd> keys pan and zoom the viewport, with the{" "}
<kbd>option</kbd>, <kbd>meta</kbd>, and <kbd>shift</kbd> keys adjusting the speed.
</li>
</ul>

<p>
Panning is enabled by default, but zooming is opt-in. The default zoom limits are{" "}
<code>0.5-5</code>
</p>

<CodeAndExample component={<ZoomExample />} source={ZoomExampleSource} />

<h2>Viewbox</h2>

<p>
Expand Down
21 changes: 21 additions & 0 deletions docs/components/guide-examples/display/viewbox/ZoomExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Mafs, Coordinates, Circle, Text } from "mafs"

export default function ZoomExample() {
return (
<Mafs
zoom={{ min: 0.1, max: 2 }}
viewBox={{
x: [-0.25, 0.25],
y: [-0.25, 0.25],
padding: 0,
}}
height={400}
>
<Coordinates.Cartesian subdivisions={5} />
<Circle center={[0, 0]} radius={1} />
<Text x={1.1} y={0.1} attach="ne">
Oh hi!
</Text>
</Mafs>
)
}
9 changes: 9 additions & 0 deletions e2e/generated-vrt.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import PolarCoordinatesExample from "../docs/components/guide-examples/display/c
import VectorExample from "../docs/components/guide-examples/display/vectors/VectorExample"
import ContainViewbox from "../docs/components/guide-examples/display/viewbox/ContainViewbox"
import StretchViewbox from "../docs/components/guide-examples/display/viewbox/StretchViewbox"
import ZoomExample from "../docs/components/guide-examples/display/viewbox/ZoomExample"

test("guide-examples/LinePointSlopeExample", async ({ mount, page }) => {
const component = await mount(<LinePointSlopeExample />)
Expand Down Expand Up @@ -302,3 +303,11 @@ test("guide-examples/display/viewbox/StretchViewbox", async ({ mount, page }) =>
: await expect(component.locator(".MafsView")).toHaveClass("MafsView")
await expect(page).toHaveScreenshot()
})

test("guide-examples/display/viewbox/ZoomExample", async ({ mount, page }) => {
const component = await mount(<ZoomExample />)
;(await component.locator(".MafsView").count()) === 0
? await expect(component).toHaveClass("MafsView")
: await expect(component.locator(".MafsView")).toHaveClass("MafsView")
await expect(page).toHaveScreenshot()
})
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const config: PlaywrightTestConfig = {
timeout: 30 * 1000,
expect: {
timeout: 5000,
toHaveScreenshot: {
maxDiffPixelRatio: 0.02,
},
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
Expand Down
7 changes: 5 additions & 2 deletions src/display/Coordinates/Axes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function XLabels({ separation, labelMaker }: LabelsProps) {
<g className="mafs-axis">
{xPanes.map(([min, max]) => (
<g key={`${min},${max}`}>
{snappedRange(min, max - separation, separation)
{snappedRange(min, max, separation)
.filter((x) => Math.abs(x) > separation / 1e6)
.map((x) => (
<text
Expand All @@ -46,6 +46,7 @@ export function XLabels({ separation, labelMaker }: LabelsProps) {
key={x}
dominantBaseline="hanging"
textAnchor="middle"
style={{ fill: "white", paintOrder: "stroke" }}
>
{labelMaker(x)}
</text>
Expand All @@ -65,14 +66,16 @@ export function YLabels({ separation, labelMaker }: LabelsProps) {
<g className="mafs-axis">
{yPanes.map(([min, max]) => (
<g key={`${min},${max}`}>
{snappedRange(min, max - separation, separation)
{snappedRange(min, max, separation)
.filter((y) => Math.abs(y) > separation / 1e6)
.map((y) => (
<text
fill="white"
x={5}
y={vec.transform([0, y], viewTransform)[1]}
key={y}
dominantBaseline="central"
style={{ fill: "white", paintOrder: "stroke" }}
>
{labelMaker(y)}
</text>
Expand Down
6 changes: 5 additions & 1 deletion src/display/Coordinates/Cartesian.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@ export function Cartesian({
}

export function snappedRange(min: number, max: number, step: number) {
return range(Math.floor(min / step) * step, Math.ceil(max / step) * step, step)
const roundMin = Math.floor(min / step) * step
const roundMax = Math.ceil(max / step) * step

if (roundMin === roundMax - step) return [roundMin]
return range(roundMin, roundMax - step, step)
}

export function autoPi(x: number): string {
Expand Down
39 changes: 39 additions & 0 deletions src/gestures/useCamera.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as React from "react"
import { clamp } from "../math"
import { vec } from "../vec"

export function useCamera({ minZoom, maxZoom }: { minZoom: number; maxZoom: number }) {
const [matrix, setMatrix] = React.useState<vec.Matrix>(vec.identity)
const initialMatrix = React.useRef<vec.Matrix>(vec.identity)

return {
matrix: matrix,
setBase() {
initialMatrix.current = matrix
},
move({ zoom, pan }: { zoom?: { at: vec.Vector2; scale?: number }; pan?: vec.Vector2 }) {
const scale = 1 / (zoom?.scale ?? 1)
const zoomAt = zoom?.at ?? [0, 0]

const currentScale = initialMatrix.current[0]
const minScale = 1 / maxZoom / currentScale
const maxScale = 1 / minZoom / currentScale

/**
* Represents the amount of scaling to apply such that we never exceed the
* minimum or maximum zoom level.
*/
const clampedScale = clamp(scale, minScale, maxScale)

const newCamera = vec
.matrixBuilder(initialMatrix.current)
.translate(...vec.scale(zoomAt, -1))
.scale(clampedScale, clampedScale)
.translate(...vec.scale(zoomAt, 1))
.translate(...(pan ?? [0, 0]))
.get()

setMatrix(newCamera)
},
}
}
35 changes: 35 additions & 0 deletions src/gestures/useWheelEnabler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as React from "react"

/**
* A custom hook that makes the `wheel` event not interrupt scrolling. It will
* only allow the Mafs viewport to be zoomed using the wheel if the user hasn't
* scrolled the page for 500ms, or if they are hovering over the Mafs viewport.
*/
export function useWheelEnabler(zoomEnabled: boolean) {
const [wheelEnabled, setWheelEnabled] = React.useState(false)

const timer = React.useRef<number>(0)

React.useEffect(() => {
if (!zoomEnabled) return

function handleWindowScroll() {
setWheelEnabled(false)

clearTimeout(timer.current)
timer.current = setTimeout(() => {
setWheelEnabled(true)
}, 500) as unknown as number
}

window.addEventListener("scroll", handleWindowScroll)
return () => window.removeEventListener("scroll", handleWindowScroll)
}, [zoomEnabled])

return {
wheelEnabled: zoomEnabled ? wheelEnabled : false,
handleMouseMove() {
setWheelEnabled(true)
},
}
}
87 changes: 46 additions & 41 deletions src/interaction/MovablePoint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,58 +47,63 @@ export function MovablePoint({

const pickup = React.useRef<vec.Vector2>([0, 0])

const bind = useDrag((state) => {
const { type, event } = state
event?.stopPropagation()

const isKeyboard = type.includes("key")
if (isKeyboard) {
event?.preventDefault()
const { direction: yDownDirection, altKey, metaKey, shiftKey } = state

const direction = [yDownDirection[0], -yDownDirection[1]] as vec.Vector2
const span = Math.abs(direction[0]) ? xSpan : ySpan

let divisions = 50
if (altKey || metaKey) divisions = 200
if (shiftKey) divisions = 10

const min = span / (divisions * 2)
const tests = range(span / divisions, span / 2, span / divisions)

for (const dx of tests) {
// Transform the test back into the point's coordinate system
const testMovement = vec.scale(direction, dx)
const testPoint = constrain(
vec.transform(
vec.add(vec.transform(point, userTransform), testMovement),
inverseTransform
const ref = React.useRef<SVGGElement>(null)

useDrag(
(state) => {
const { type, event } = state
event?.stopPropagation()

const isKeyboard = type.includes("key")
if (isKeyboard) {
event?.preventDefault()
const { direction: yDownDirection, altKey, metaKey, shiftKey } = state

const direction = [yDownDirection[0], -yDownDirection[1]] as vec.Vector2
const span = Math.abs(direction[0]) ? xSpan : ySpan

let divisions = 50
if (altKey || metaKey) divisions = 200
if (shiftKey) divisions = 10

const min = span / (divisions * 2)
const tests = range(span / divisions, span / 2, span / divisions)

for (const dx of tests) {
// Transform the test back into the point's coordinate system
const testMovement = vec.scale(direction, dx)
const testPoint = constrain(
vec.transform(
vec.add(vec.transform(point, userTransform), testMovement),
inverseTransform
)
)
)

if (vec.dist(testPoint, point) > min) {
onMove(testPoint)
break
if (vec.dist(testPoint, point) > min) {
onMove(testPoint)
break
}
}
}
} else {
const { last, movement: pixelMovement, first } = state
} else {
const { last, movement: pixelMovement, first } = state

setDragging(!last)
setDragging(!last)

if (first) pickup.current = vec.transform(point, userTransform)
if (vec.mag(pixelMovement) === 0) return
if (first) pickup.current = vec.transform(point, userTransform)
if (vec.mag(pixelMovement) === 0) return

const movement = vec.transform(pixelMovement, inverseViewTransform)
onMove(constrain(vec.transform(vec.add(pickup.current, movement), inverseTransform)))
}
})
const movement = vec.transform(pixelMovement, inverseViewTransform)
onMove(constrain(vec.transform(vec.add(pickup.current, movement), inverseTransform)))
}
},
{ target: ref, eventOptions: { passive: false } }
)

const ringSize = 15

return (
<g
{...bind()}
ref={ref}
style={
{
"--movable-point-color": color,
Expand Down
Loading

1 comment on commit 7051e5a

@vercel
Copy link

@vercel vercel bot commented on 7051e5a Jan 25, 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.