diff --git a/examples/interactive-animation.fixture.tsx b/examples/interactive-animation.fixture.tsx
new file mode 100644
index 0000000..1e21e8d
--- /dev/null
+++ b/examples/interactive-animation.fixture.tsx
@@ -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 (
+
+
Animation Demo
+
+ Elements with animationKey will animate smoothly when their positions
+ change.
+
+
+
+
+
+
+ )
+}
diff --git a/lib/types.ts b/lib/types.ts
index b90e1d5..be6177a 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -5,6 +5,7 @@ export interface Point {
label?: string
layer?: string
step?: number
+ animationKey?: string
}
export interface Line {
@@ -15,6 +16,7 @@ export interface Line {
layer?: string
step?: number
label?: string
+ animationKey?: string
}
export interface Rect {
diff --git a/site/components/InteractiveGraphics/InteractiveGraphics.tsx b/site/components/InteractiveGraphics/InteractiveGraphics.tsx
index 78c437d..b318fcc 100644
--- a/site/components/InteractiveGraphics/InteractiveGraphics.tsx
+++ b/site/components/InteractiveGraphics/InteractiveGraphics.tsx
@@ -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"
@@ -19,6 +19,12 @@ import {
useFilterCircles,
} from "./hooks"
+// Type for tracking animated elements
+type AnimatedElementsMap = {
+ lines: Record
+ points: Record
+}
+
export type GraphicsObjectClickEvent = {
type: "point" | "line" | "rect" | "circle"
index: number
@@ -35,6 +41,12 @@ export const InteractiveGraphics = ({
const [activeLayers, setActiveLayers] = useState(null)
const [activeStep, setActiveStep] = useState(null)
const [size, setSize] = useState({ width: 600, height: 600 })
+ const [animatedElements, setAnimatedElements] = useState(
+ {
+ lines: {},
+ points: {},
+ },
+ )
const availableLayers: string[] = Array.from(
new Set([
...(graphics.lines?.map((l) => l.layer!).filter(Boolean) ?? []),
@@ -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)
@@ -208,7 +278,11 @@ export const InteractiveGraphics = ({
{graphics.lines?.map((l, originalIndex) =>
filterLines(l) ? (
filterPoints(p) ? (
void
+ animatedElements?: {
+ lines: Record
+ points: Record
+ }
}
diff --git a/site/components/InteractiveGraphics/Line.tsx b/site/components/InteractiveGraphics/Line.tsx
index 58ff7bb..5f95433 100644
--- a/site/components/InteractiveGraphics/Line.tsx
+++ b/site/components/InteractiveGraphics/Line.tsx
@@ -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"
@@ -13,8 +13,13 @@ 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,
@@ -22,11 +27,95 @@ export const Line = ({
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(null)
+ const startTimeRef = useRef(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()
diff --git a/site/components/InteractiveGraphics/Point.tsx b/site/components/InteractiveGraphics/Point.tsx
index 8865c5a..410498a 100644
--- a/site/components/InteractiveGraphics/Point.tsx
+++ b/site/components/InteractiveGraphics/Point.tsx
@@ -1,7 +1,7 @@
import type * as Types from "lib/types"
import { applyToPoint } from "transformation-matrix"
import type { InteractiveState } from "./InteractiveState"
-import { useState } from "react"
+import { useState, useEffect, useRef } from "react"
import { Tooltip } from "./Tooltip"
import { defaultColors } from "./defaultColors"
import { safeLighten } from "site/utils/safeLighten"
@@ -15,12 +15,109 @@ export const Point = ({
interactiveState: InteractiveState
index: number
}) => {
- const { color, label, layer, step } = point
- const { activeLayers, activeStep, realToScreen, onObjectClicked } =
- interactiveState
+ const { color, label, layer, step, animationKey } = point
+ const {
+ activeLayers,
+ activeStep,
+ realToScreen,
+ onObjectClicked,
+ animatedElements,
+ } = interactiveState
const [isHovered, setIsHovered] = useState(false)
- const screenPoint = applyToPoint(realToScreen, point)
+ // For animation
+ const [animatedPoint, setAnimatedPoint] = useState<{ x: number; y: number }>({
+ x: point.x,
+ y: point.y,
+ })
+ const [isAnimating, setIsAnimating] = useState(false)
+ const animationRef = useRef(null)
+ const startTimeRef = useRef(0)
+ const prevPointRef = useRef<{ x: number; y: number }>({
+ x: point.x,
+ y: point.y,
+ })
+ const targetPointRef = useRef<{ x: number; y: number }>({
+ x: point.x,
+ y: point.y,
+ })
+
+ // 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 point
+ const newPoint = {
+ x:
+ prevPointRef.current.x +
+ (targetPointRef.current.x - prevPointRef.current.x) * easedProgress,
+ y:
+ prevPointRef.current.y +
+ (targetPointRef.current.y - prevPointRef.current.y) * easedProgress,
+ }
+
+ setAnimatedPoint(newPoint)
+
+ if (progress < 1) {
+ animationRef.current = requestAnimationFrame(animate)
+ } else {
+ setIsAnimating(false)
+ startTimeRef.current = 0
+ animationRef.current = null
+ }
+ }
+
+ // Start animation when point changes and we have an animation key
+ useEffect(() => {
+ if (!animationKey) {
+ setAnimatedPoint({ x: point.x, y: point.y })
+ return
+ }
+
+ // Get stored previous point for this animation key
+ const storedPoint = animatedElements?.points[animationKey]
+
+ if (
+ storedPoint &&
+ (point.x !== storedPoint.x || point.y !== storedPoint.y)
+ ) {
+ // Start a new animation
+ if (animationRef.current) {
+ cancelAnimationFrame(animationRef.current)
+ }
+
+ prevPointRef.current = storedPoint
+ targetPointRef.current = { x: point.x, y: point.y }
+ setIsAnimating(true)
+ startTimeRef.current = 0
+ animationRef.current = requestAnimationFrame(animate)
+ } else if (!isAnimating) {
+ // If not animating, just update point directly
+ setAnimatedPoint({ x: point.x, y: point.y })
+ }
+
+ // Clean up animation on unmount
+ return () => {
+ if (animationRef.current) {
+ cancelAnimationFrame(animationRef.current)
+ }
+ }
+ }, [point.x, point.y, animationKey, animatedElements])
+
+ // Map animated point to screen coordinates
+ const screenPoint = applyToPoint(realToScreen, animatedPoint)
const size = 10
return (