Skip to content

Commit

Permalink
Extract hook and display component from MovablePoint (#144)
Browse files Browse the repository at this point in the history
We at @Khan would like to add movable circles, polygons, and line
segments
to Mafs graphs. For the sake of keeping the UX consistent, we'd like the
movement interaction for these objects to reuse the hook from
MovablePoint,
but that hook currently isn't exported.

We're also thinking that we may want to customize e.g. the size of the
movable points.

To that end, this PR splits MovablePoint into a hook and a
presentational
component (both exported), so that these aspects can be reused and
changed
independently.

@stevenpetryk I'd welcome any feedback you have. I'm especially looking
for
feedback on naming and code organization.

---------

Co-authored-by: Steven Petryk <[email protected]>
  • Loading branch information
benchristel and stevenpetryk authored Mar 7, 2024
1 parent e1eba6a commit 1d638ec
Show file tree
Hide file tree
Showing 7 changed files with 204 additions and 105 deletions.
36 changes: 36 additions & 0 deletions .api-report/mafs.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,21 @@ export namespace MovablePoint {
displayName: string;
}

// @public (undocumented)
export const MovablePointDisplay: React_2.ForwardRefExoticComponent<MovablePointDisplayProps & React_2.RefAttributes<SVGGElement>>;

// @public (undocumented)
export interface MovablePointDisplayProps {
// (undocumented)
color?: string;
// (undocumented)
dragging: boolean;
// (undocumented)
point: vec.Vector2;
// (undocumented)
ringRadiusPx?: number;
}

// @public (undocumented)
export interface MovablePointProps {
// (undocumented)
Expand Down Expand Up @@ -349,6 +364,27 @@ export type TransformProps = React_2.PropsWithChildren<{
shear?: vec.Vector2;
}>;

// @public (undocumented)
export interface UseMovable {
// (undocumented)
dragging: boolean;
}

// @public (undocumented)
export function useMovable(args: UseMovableArguments): UseMovable;

// @public (undocumented)
export interface UseMovableArguments {
// (undocumented)
constrain: (point: vec.Vector2) => vec.Vector2;
// (undocumented)
gestureTarget: React_2.RefObject<Element>;
// (undocumented)
onMove: (point: vec.Vector2) => unknown;
// (undocumented)
point: vec.Vector2;
}

// @public (undocumented)
export interface UseMovablePoint {
// (undocumented)
Expand Down
7 changes: 7 additions & 0 deletions src/display/MovablePointDisplay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { MovablePointDisplay } from "./MovablePointDisplay"

describe("MovablePointDisplay", () => {
it("has a human-readable displayName", () => {
expect(MovablePointDisplay.displayName).toBe("MovablePointDisplay")
})
})
52 changes: 52 additions & 0 deletions src/display/MovablePointDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as React from "react"
import { vec } from "../vec"
import { useTransformContext } from "../context/TransformContext"
import { Theme } from "./Theme"

export interface MovablePointDisplayProps {
color?: string
ringRadiusPx?: number
dragging: boolean
point: vec.Vector2
}

export const MovablePointDisplay = React.forwardRef<SVGGElement, MovablePointDisplayProps>(
(props: MovablePointDisplayProps, ref) => {
const { color = Theme.pink, ringRadiusPx = 15, dragging, point } = props

const { viewTransform, userTransform } = useTransformContext()

const combinedTransform = React.useMemo(
() => vec.matrixMult(viewTransform, userTransform),
[viewTransform, userTransform],
)

const [xPx, yPx] = vec.transform(point, combinedTransform)

return (
<g
ref={ref}
style={
{
"--movable-point-color": color,
"--movable-point-ring-size": `${ringRadiusPx}px`,
} as React.CSSProperties
}
className={`mafs-movable-point ${dragging ? "mafs-movable-point-dragging" : ""}`}
tabIndex={0}
>
<circle className="mafs-movable-point-hitbox" r={30} cx={xPx} cy={yPx}></circle>
<circle
className="mafs-movable-point-focus"
r={ringRadiusPx + 1}
cx={xPx}
cy={yPx}
></circle>
<circle className="mafs-movable-point-ring" r={ringRadiusPx} cx={xPx} cy={yPx}></circle>
<circle className="mafs-movable-point-point" r={6} cx={xPx} cy={yPx}></circle>
</g>
)
},
)

MovablePointDisplay.displayName = "MovablePointDisplay"
6 changes: 6 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export type { Filled, Stroked } from "./display/Theme"
export { MovablePoint } from "./interaction/MovablePoint"
export type { MovablePointProps } from "./interaction/MovablePoint"

export { MovablePointDisplay } from "./display/MovablePointDisplay"
export type { MovablePointDisplayProps } from "./display/MovablePointDisplay"

export { useMovable } from "./interaction/useMovable"
export type { UseMovable, UseMovableArguments } from "./interaction/useMovable"

export { useMovablePoint } from "./interaction/useMovablePoint"
export type {
ConstraintFunction,
Expand Down
7 changes: 7 additions & 0 deletions src/interaction/MovablePoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { MovablePoint } from "./MovablePoint"

describe("MovablePoint", () => {
it("has a human-readable displayName", () => {
expect(MovablePoint.displayName).toBe("MovablePoint")
})
})
109 changes: 4 additions & 105 deletions src/interaction/MovablePoint.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { useDrag } from "@use-gesture/react"
import * as React from "react"
import invariant from "tiny-invariant"
import { Theme } from "../display/Theme"
import { range } from "../math"
import { vec } from "../vec"
import { useTransformContext } from "../context/TransformContext"
import { useSpanContext } from "../context/SpanContext"
import { useMovable } from "./useMovable"
import { MovablePointDisplay } from "../display/MovablePointDisplay"

export type ConstraintFunction = (position: vec.Vector2) => vec.Vector2

Expand All @@ -30,109 +27,11 @@ export function MovablePoint({
constrain = (point) => point,
color = Theme.pink,
}: MovablePointProps) {
const { viewTransform, userTransform } = useTransformContext()
const { xSpan, ySpan } = useSpanContext()
const inverseViewTransform = vec.matrixInvert(viewTransform)
invariant(inverseViewTransform, "The view transform must be invertible.")

const inverseTransform = React.useMemo(() => getInverseTransform(userTransform), [userTransform])

const combinedTransform = React.useMemo(
() => vec.matrixMult(viewTransform, userTransform),
[viewTransform, userTransform],
)

const [dragging, setDragging] = React.useState(false)
const [displayX, displayY] = vec.transform(point, combinedTransform)

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

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)
const { dragging } = useMovable({ gestureTarget: ref, onMove, point, constrain })

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
}
}
} else {
const { last, movement: pixelMovement, first } = state

setDragging(!last)

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)))
}
},
{ target: ref, eventOptions: { passive: false } },
)

const ringSize = 15

return (
<g
ref={ref}
style={
{
"--movable-point-color": color,
"--movable-point-ring-size": `${ringSize}px`,
} as React.CSSProperties
}
className={`mafs-movable-point ${dragging ? "mafs-movable-point-dragging" : ""}`}
tabIndex={0}
>
<circle className="mafs-movable-point-hitbox" r={30} cx={displayX} cy={displayY}></circle>
<circle
className="mafs-movable-point-focus"
r={ringSize + 1}
cx={displayX}
cy={displayY}
></circle>
<circle className="mafs-movable-point-ring" r={ringSize} cx={displayX} cy={displayY}></circle>
<circle className="mafs-movable-point-point" r={6} cx={displayX} cy={displayY}></circle>
</g>
)
return <MovablePointDisplay ref={ref} point={point} color={color} dragging={dragging} />
}

MovablePoint.displayName = "MovablePoint"

function getInverseTransform(transform: vec.Matrix) {
const invert = vec.matrixInvert(transform)
invariant(
invert !== null,
"Could not invert transform matrix. Your movable point's transformation matrix might be degenerative (mapping 2D space to a line).",
)
return invert
}
92 changes: 92 additions & 0 deletions src/interaction/useMovable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from "react"
import { useDrag } from "@use-gesture/react"
import invariant from "tiny-invariant"
import { vec } from "../vec"
import { useSpanContext } from "../context/SpanContext"
import { useTransformContext } from "../context/TransformContext"
import { range } from "../math"

export interface UseMovableArguments {
gestureTarget: React.RefObject<Element>
onMove: (point: vec.Vector2) => unknown
point: vec.Vector2
constrain: (point: vec.Vector2) => vec.Vector2
}

export interface UseMovable {
dragging: boolean
}

export function useMovable(args: UseMovableArguments): UseMovable {
const { gestureTarget: target, onMove, point, constrain } = args
const [dragging, setDragging] = React.useState(false)
const { xSpan, ySpan } = useSpanContext()
const { viewTransform, userTransform } = useTransformContext()

const inverseViewTransform = vec.matrixInvert(viewTransform)
invariant(inverseViewTransform, "The view transform must be invertible.")

const inverseTransform = React.useMemo(() => getInverseTransform(userTransform), [userTransform])

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

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
}
}
} else {
const { last, movement: pixelMovement, first } = state

setDragging(!last)

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)))
}
},
{ target, eventOptions: { passive: false } },
)
return { dragging }
}

function getInverseTransform(transform: vec.Matrix) {
const invert = vec.matrixInvert(transform)
invariant(
invert !== null,
"Could not invert transform matrix. A parent transformation matrix might be degenerative (mapping 2D space to a line).",
)
return invert
}

0 comments on commit 1d638ec

Please sign in to comment.