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 (