Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions examples/interactive-animation.fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React, { useState, useCallback } from "react"
import { InteractiveGraphics } from "../site/components/InteractiveGraphics/InteractiveGraphics"
import { GraphicsObject } from "../lib"

export default () => {
const [position, setPosition] = useState(0)

const updatePosition = useCallback(() => {
setPosition((prev) => (prev + 1) % 4)
}, [])

// Create a sample graphics object with animationKey elements
const graphics: GraphicsObject = {
points: [
// Animated point that moves in a square pattern
{
x: position === 0 || position === 3 ? 0 : 100,
y: position === 0 || position === 1 ? 0 : 100,
color: "#ff0000",
label: "Animated Point",
animationKey: "point1",
},
// Static point for comparison
{
x: 150,
y: 50,
color: "#0000ff",
label: "Static Point",
},
],
lines: [
// Animated line with changing points
{
points: [
{ x: position * 20, y: 0 },
{ x: 100, y: 100 - position * 20 },
],
strokeColor: "#00aa00",
strokeWidth: 2,
label: "Animated Line",
animationKey: "line1",
},
// Static line for comparison
{
points: [
{ x: 150, y: 150 },
{ x: 200, y: 200 },
],
strokeColor: "#aa00aa",
strokeWidth: 2,
label: "Static Line",
},
],
}

return (
<div>
<h2>Animation Demo</h2>
<p>
Elements with animationKey will animate smoothly when their positions
change.
</p>
<button
onClick={updatePosition}
style={{ margin: "10px 0", padding: "5px 10px" }}
>
Move Elements
</button>
<div style={{ border: "1px solid #ccc", padding: 10 }}>
<InteractiveGraphics graphics={graphics} />
</div>
</div>
)
}
2 changes: 2 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface Point {
label?: string
layer?: string
step?: number
animationKey?: string
}

export interface Line {
Expand All @@ -15,6 +16,7 @@ export interface Line {
layer?: string
step?: number
label?: string
animationKey?: string
}

export interface Rect {
Expand Down
84 changes: 81 additions & 3 deletions site/components/InteractiveGraphics/InteractiveGraphics.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { compose, scale, translate } from "transformation-matrix"
import { GraphicsObject } from "../../../lib"
import { useMemo, useState } from "react"
import { useMemo, useState, useRef, useEffect } from "react"
import useMouseMatrixTransform from "use-mouse-matrix-transform"
import { InteractiveState } from "./InteractiveState"
import { SuperGrid } from "react-supergrid"
Expand All @@ -19,6 +19,12 @@ import {
useFilterCircles,
} from "./hooks"

// Type for tracking animated elements
type AnimatedElementsMap = {
lines: Record<string, { points: { x: number; y: number }[] }>
points: Record<string, { x: number; y: number }>
}

export type GraphicsObjectClickEvent = {
type: "point" | "line" | "rect" | "circle"
index: number
Expand All @@ -35,6 +41,12 @@ export const InteractiveGraphics = ({
const [activeLayers, setActiveLayers] = useState<string[] | null>(null)
const [activeStep, setActiveStep] = useState<number | null>(null)
const [size, setSize] = useState({ width: 600, height: 600 })
const [animatedElements, setAnimatedElements] = useState<AnimatedElementsMap>(
{
lines: {},
points: {},
},
)
const availableLayers: string[] = Array.from(
new Set([
...(graphics.lines?.map((l) => l.layer!).filter(Boolean) ?? []),
Expand Down Expand Up @@ -97,10 +109,68 @@ export const InteractiveGraphics = ({
activeStep: activeStep,
realToScreen: realToScreen,
onObjectClicked: onObjectClicked,
animatedElements: animatedElements,
}

const showToolbar = availableLayers.length > 1 || maxStep > 0

// Effect to track elements with animationKey
useEffect(() => {
// Create a copy of current state to build the new state
const newAnimatedElements: AnimatedElementsMap = {
lines: {},
points: {},
}

// First, copy all existing animation keys (for cases where elements were removed)
Object.keys(animatedElements.lines).forEach((key) => {
newAnimatedElements.lines[key] = animatedElements.lines[key]
})

Object.keys(animatedElements.points).forEach((key) => {
newAnimatedElements.points[key] = animatedElements.points[key]
})

// Then process current graphics objects
// Track lines with animationKey
graphics.lines?.forEach((line) => {
if (line.animationKey) {
// Only update if we don't already have this key or the points have changed
const existingLine = animatedElements.lines[line.animationKey]

if (!existingLine) {
// First time we're seeing this line
newAnimatedElements.lines[line.animationKey] = {
points: [...line.points],
}
}
// Otherwise keep the existing entry for animation purposes
}
})

// Track points with animationKey
graphics.points?.forEach((point) => {
if (point.animationKey) {
// Only update if we don't already have this key or the point has changed
const existingPoint = animatedElements.points[point.animationKey]

if (!existingPoint) {
// First time we're seeing this point
newAnimatedElements.points[point.animationKey] = {
x: point.x,
y: point.y,
}
}
// Otherwise keep the existing entry for animation purposes
}
})

// After animation completes, we need to update the stored positions
// This is handled in the individual components

setAnimatedElements(newAnimatedElements)
}, [graphics])

// Use custom hooks for visibility checks and filtering
const isPointOnScreen = useIsPointOnScreen(realToScreen, size)

Expand Down Expand Up @@ -208,7 +278,11 @@ export const InteractiveGraphics = ({
{graphics.lines?.map((l, originalIndex) =>
filterLines(l) ? (
<Line
key={originalIndex}
key={
l.animationKey
? `line-${l.animationKey}`
: `line-${originalIndex}`
}
line={l}
index={originalIndex}
interactiveState={interactiveState}
Expand All @@ -228,7 +302,11 @@ export const InteractiveGraphics = ({
{graphics.points?.map((p, originalIndex) =>
filterPoints(p) ? (
<Point
key={originalIndex}
key={
p.animationKey
? `point-${p.animationKey}`
: `point-${originalIndex}`
}
point={p}
index={originalIndex}
interactiveState={interactiveState}
Expand Down
4 changes: 4 additions & 0 deletions site/components/InteractiveGraphics/InteractiveState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@ export type InteractiveState = {
activeStep: number | null
realToScreen: Matrix
onObjectClicked?: (event: GraphicsObjectClickEvent) => void
animatedElements?: {
lines: Record<string, { points: { x: number; y: number }[] }>
points: Record<string, { x: number; y: number }>
}
}
97 changes: 93 additions & 4 deletions site/components/InteractiveGraphics/Line.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type * as Types from "lib/types"
import { applyToPoint } from "transformation-matrix"
import type { InteractiveState } from "./InteractiveState"
import { lighten } from "polished"
import { useState } from "react"
import { useState, useRef, useEffect } from "react"
import { Tooltip } from "./Tooltip"
import { distToLineSegment } from "site/utils/distToLineSegment"
import { defaultColors } from "./defaultColors"
Expand All @@ -13,20 +13,109 @@ export const Line = ({
index,
interactiveState,
}: { line: Types.Line; index: number; interactiveState: InteractiveState }) => {
const { activeLayers, activeStep, realToScreen, onObjectClicked } =
interactiveState
const {
activeLayers,
activeStep,
realToScreen,
onObjectClicked,
animatedElements,
} = interactiveState
const {
points,
layer,
step,
strokeColor,
strokeWidth = 1 / realToScreen.a,
strokeDash,
animationKey,
} = line
const [isHovered, setIsHovered] = useState(false)
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })

const screenPoints = points.map((p) => applyToPoint(realToScreen, p))
// For animation
const [animatedPoints, setAnimatedPoints] =
useState<{ x: number; y: number }[]>(points)
const [isAnimating, setIsAnimating] = useState(false)
const animationRef = useRef<number | null>(null)
const startTimeRef = useRef<number>(0)
const prevPointsRef = useRef<{ x: number; y: number }[]>(points)
const targetPointsRef = useRef<{ x: number; y: number }[]>(points)

// Animation function
const animate = (timestamp: number) => {
if (!startTimeRef.current) {
startTimeRef.current = timestamp
}

const elapsed = timestamp - startTimeRef.current
const duration = 500 // Animation duration in ms
const progress = Math.min(elapsed / duration, 1)

// Easing function (ease-in-out)
const easedProgress =
progress < 0.5
? 2 * progress * progress
: 1 - Math.pow(-2 * progress + 2, 2) / 2

// Interpolate between previous and current points
const newPoints = prevPointsRef.current.map((prevPoint, i) => {
const targetPoint = targetPointsRef.current[i]
return {
x: prevPoint.x + (targetPoint.x - prevPoint.x) * easedProgress,
y: prevPoint.y + (targetPoint.y - prevPoint.y) * easedProgress,
}
})

setAnimatedPoints(newPoints)

if (progress < 1) {
animationRef.current = requestAnimationFrame(animate)
} else {
setIsAnimating(false)
startTimeRef.current = 0
animationRef.current = null
}
}

// Start animation when points change and we have an animation key
useEffect(() => {
if (!animationKey) {
setAnimatedPoints(points)
return
}

// Get stored previous points for this animation key
const storedLine = animatedElements?.lines[animationKey]

if (
storedLine &&
JSON.stringify(points) !== JSON.stringify(storedLine.points)
) {
// Start a new animation
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}

prevPointsRef.current = storedLine.points
targetPointsRef.current = points
setIsAnimating(true)
startTimeRef.current = 0
animationRef.current = requestAnimationFrame(animate)
} else if (!isAnimating) {
// If not animating, just update points directly
setAnimatedPoints(points)
}

// Clean up animation on unmount
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [points, animationKey, animatedElements])

// Map animated points to screen coordinates
const screenPoints = animatedPoints.map((p) => applyToPoint(realToScreen, p))

const handleMouseMove = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect()
Expand Down
Loading