diff --git a/package.json b/package.json index e360e96..755a1dc 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "fumadocs-core": "16.9.3", "fumadocs-mdx": "15.0.10", "fumadocs-ui": "16.9.3", + "gsap": "^3.15.0", "lucide-react": "^1.17.0", "posthog-js": "^1.376.6", "radix-ui": "^1.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df89cad..975541d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: fumadocs-ui: specifier: 16.9.3 version: 16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0) + gsap: + specifier: ^3.15.0 + version: 3.15.0 lucide-react: specifier: ^1.17.0 version: 1.17.0(react@19.2.6) @@ -2726,6 +2729,9 @@ packages: resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gsap@3.15.0: + resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} + h3@2.0.1-rc.20: resolution: {integrity: sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==} engines: {node: '>=20.11.1'} @@ -6842,6 +6848,8 @@ snapshots: graphql@16.14.0: {} + gsap@3.15.0: {} + h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.16)): dependencies: rou3: 0.8.1 diff --git a/src/components/AnimatedSVG.tsx b/src/components/AnimatedSVG.tsx new file mode 100644 index 0000000..9b7e986 --- /dev/null +++ b/src/components/AnimatedSVG.tsx @@ -0,0 +1,277 @@ +/** + * AnimatedSVG Component + * + * A React component wrapper for SVG animations using GSAP. + * Provides an easy way to add animations to SVG diagrams in Docusaurus. + * + * @example + * ```tsx + * import AnimatedSVG from '@site/src/components/AnimatedSVG'; + * + * { + * animator + * .fadeIn('g[id="node-1"]', { duration: 1 }) + * .drawPath('g[id="arrow-1"] path', { duration: 2 }) + * .play(); + * }} + * autoPlay={true} + * /> + * ``` + */ + +import React, { useEffect, useRef, useState } from "react"; +import { SVGAnimator } from "../lib/svg-animator"; + +interface AnimatedSVGProps { + /** URL or path to the SVG file */ + src: string; + /** Animation callback that receives the animator instance */ + onAnimate?: (animator: SVGAnimator) => void; + /** Whether to auto-play the animation on mount */ + autoPlay?: boolean; + /** Whether to show manual controls (forward/reverse buttons) */ + showControls?: boolean; + /** Whether to show restart button only */ + showRestartButton?: boolean; + /** Additional CSS classes */ + className?: string; + /** Additional inline styles */ + style?: React.CSSProperties; + /** Alt text for accessibility */ + alt?: string; + /** Width of the SVG container */ + width?: string | number; + /** Height of the SVG container */ + height?: string | number; +} + +const AnimatedSVG: React.FC = ({ + src, + onAnimate, + autoPlay = false, + showControls = false, + showRestartButton = false, + className = "", + style = {}, + alt = "Animated SVG", + width, + height, +}) => { + const containerRef = useRef(null); + const animatorRef = useRef(null); + const [isLoaded, setIsLoaded] = useState(false); + const [canGoNext, setCanGoNext] = useState(true); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // Fetch and inject SVG + const loadSVG = async () => { + try { + const response = await fetch(src); + const svgText = await response.text(); + container.innerHTML = svgText; + + const svgElement = container.querySelector("svg"); + if (!svgElement) { + console.error("No SVG element found in the loaded content"); + return; + } + + // Set dimensions if provided + if (width) + svgElement.style.width = + typeof width === "number" ? `${width}px` : width; + if (height) + svgElement.style.height = + typeof height === "number" ? `${height}px` : height; + + // Create animator instance + animatorRef.current = new SVGAnimator(svgElement); + setIsLoaded(true); + + // Call animation callback + if (onAnimate && animatorRef.current) { + onAnimate(animatorRef.current); + + if (autoPlay) { + animatorRef.current.play(); + } + } + } catch (error) { + console.error("Error loading SVG:", error); + } + }; + + loadSVG(); + + // Cleanup + return () => { + if (animatorRef.current) { + animatorRef.current.kill(); + } + }; + }, [src, onAnimate, autoPlay, width, height]); + + const updateButtonStates = () => { + if (animatorRef.current) { + setCanGoNext(animatorRef.current.hasNextStep()); + } + }; + + const handleNext = () => { + if (animatorRef.current) { + animatorRef.current.nextStep(); + updateButtonStates(); + } + }; + + const handleReset = () => { + // Reload the component by toggling isLoaded + setIsLoaded(false); + setCanGoNext(true); + + // Re-trigger the useEffect by incrementing a key would be cleaner, + // but we can also just re-run the SVG loading logic + const container = containerRef.current; + if (!container) return; + + // Kill existing animator + if (animatorRef.current) { + animatorRef.current.kill(); + animatorRef.current = null; + } + + // Reload SVG + const loadSVG = async () => { + try { + const response = await fetch(src); + const svgText = await response.text(); + container.innerHTML = svgText; + + const svgElement = container.querySelector("svg"); + if (!svgElement) { + console.error("No SVG element found in the loaded content"); + return; + } + + // Set dimensions if provided + if (width) + svgElement.style.width = + typeof width === "number" ? `${width}px` : width; + if (height) + svgElement.style.height = + typeof height === "number" ? `${height}px` : height; + + // Create animator instance + animatorRef.current = new SVGAnimator(svgElement); + setIsLoaded(true); + + // Call animation callback + if (onAnimate && animatorRef.current) { + onAnimate(animatorRef.current); + + // Auto-play if enabled + if (autoPlay) { + animatorRef.current.play(); + } + } + } catch (error) { + console.error("Error loading SVG:", error); + } + }; + + loadSVG(); + }; + + /* + * Markdown images are wrapped in a paragraph, and a
anywhere inside a + *

makes the browser close the paragraph early — which desyncs the parsed + * DOM from React's tree and breaks hydration. Every wrapper here is therefore + * a carrying an explicit display. + */ + return ( + + + {showControls && isLoaded && ( + + + + + )} + {showRestartButton && !showControls && isLoaded && ( + + + + )} + + ); +}; + +export default AnimatedSVG; diff --git a/src/components/animated-figure.tsx b/src/components/animated-figure.tsx new file mode 100644 index 0000000..38e2818 --- /dev/null +++ b/src/components/animated-figure.tsx @@ -0,0 +1,38 @@ +import defaultMdxComponents from 'fumadocs-ui/mdx'; +import type { ImgHTMLAttributes } from 'react'; +import AnimatedSVG from '@/components/AnimatedSVG'; +import { getFigureAnimation } from '@/components/consensus-animations'; + +const DefaultImg = defaultMdxComponents.img as ( + props: ImgHTMLAttributes, +) => React.ReactNode; + +/** + * Renders markdown images normally, except for the consensus figures whose SVG + * is a stack of animation states. Those are handed to AnimatedSVG so the states + * are revealed one step at a time instead of all at once. + */ +export function AnimatedFigure(props: ImgHTMLAttributes) { + const { src, alt } = props; + + if (typeof src === 'string' && typeof alt === 'string') { + const spec = getFigureAnimation(src, alt); + if (spec) { + return ( + + ); + } + } + + return ; +} diff --git a/src/components/consensus-animations.ts b/src/components/consensus-animations.ts new file mode 100644 index 0000000..34cba25 --- /dev/null +++ b/src/components/consensus-animations.ts @@ -0,0 +1,219 @@ +import type { CSSProperties } from 'react'; +import type { SVGAnimator } from '@/lib/svg-animator'; +import { part06Fig1 } from '@/components/part06Fig1'; +import { part06Fig2Scenario1 } from '@/components/part06Fig2Scenario1'; +import { part06Fig2Scenario2 } from '@/components/part06Fig2Scenario2'; +import { part06Fig2Scenario3 } from '@/components/part06Fig2Scenario3'; +import { part06Fig3 } from '@/components/part06Fig3'; +import { part07Fig3Raft } from '@/components/part07Fig3Raft'; +import { part07Fig3Scenario2 } from '@/components/part07Fig3Scenario2'; +import { part07Fig3Scenario3 } from '@/components/part07Fig3Scenario3'; +import { part07Fig3Scenario4 } from '@/components/part07Fig3Scenario4'; +import { part07Fig3Scenario5 } from '@/components/part07Fig3Scenario5'; +import { part08Fig3a } from '@/components/part08Fig3a'; +import { part08Fig3b } from '@/components/part08Fig3b'; +import { part08Fig3c } from '@/components/part08Fig3c'; +import { requestProcessing } from '@/components/requestProcessing'; + +/** + * Mirrors the props each figure was authored with before the Fumadocs + * migration. Kept as data so the restored AnimatedSVG receives exactly what it + * used to: most figures step manually, one autoplays with a replay button, and + * the two 2000px-wide ones are deliberately shifted left. + */ +type FigureAnimation = { + /** Base name of the SVG the animation drives, without directory or content hash */ + svg: string; + animate: (animator: SVGAnimator) => void; + autoPlay: boolean; + showControls: boolean; + showRestartButton?: boolean; + width?: number; + height?: number; + style?: CSSProperties; +}; + +const BLOCK: CSSProperties = { + display: 'block', + margin: '1rem 0', + overflow: 'visible', +}; + +/** part06-fig3 is 2000px wide and authored to hang left of the text column */ +const SHIFTED_LEFT: CSSProperties = { + display: 'inline-block', + margin: '1rem 0', + overflow: 'visible', + transform: 'translateX(-450px)', +}; + +/** + * The consensus figures are single SVGs holding every state of a sequence, + * driven by a GSAP timeline that reveals one step at a time. Several figures + * share one SVG and differ only in the timeline, so the caption identifies them. + */ +const FIGURE_ANIMATIONS: Record = { + 'Multigres consensus and replication diagram': { + svg: 'requestProcessing', + animate: requestProcessing, + autoPlay: false, + showControls: true, + }, + 'Figure 1: Revocation methods': { + svg: 'part06-fig1', + animate: part06Fig1, + autoPlay: true, + showControls: false, + showRestartButton: true, + width: 900, + height: 300, + style: BLOCK, + }, + 'Figure 2: Scenario 1 - No race': { + svg: 'part06-fig2', + animate: part06Fig2Scenario1, + autoPlay: false, + showControls: true, + width: 1200, + height: 400, + style: BLOCK, + }, + 'Figure 2: Scenario 2 - Newer term steals nodes': { + svg: 'part06-fig2', + animate: part06Fig2Scenario2, + autoPlay: false, + showControls: true, + width: 1200, + height: 400, + style: BLOCK, + }, + 'Figure 2: Scenario 3 - Newer term starts after scenario 1': { + svg: 'part06-fig2', + animate: part06Fig2Scenario3, + autoPlay: false, + showControls: true, + width: 1200, + height: 400, + style: BLOCK, + }, + 'Figure 3: All possible leaders': { + svg: 'part06-fig3', + animate: part06Fig3, + autoPlay: false, + showControls: true, + width: 2000, + height: 700, + style: SHIFTED_LEFT, + }, + 'Figure 1: Term number competition': { + svg: 'part06-fig3', + animate: part06Fig3, + autoPlay: false, + showControls: true, + width: 2000, + height: 700, + style: SHIFTED_LEFT, + }, + 'Figure 3: Raft timeline propagation': { + svg: 'part07-fig3', + animate: part07Fig3Raft, + autoPlay: false, + showControls: true, + width: 1000, + height: 500, + style: BLOCK, + }, + 'Figure 4: Initial state': { + svg: 'part07-fig3', + animate: part07Fig3Raft, + autoPlay: false, + showControls: false, + width: 1000, + height: 500, + style: BLOCK, + }, + 'Figure 5: Scenario 2': { + svg: 'part07-fig3', + animate: part07Fig3Scenario2, + autoPlay: false, + showControls: true, + width: 1000, + height: 500, + style: BLOCK, + }, + 'Figure 6: Scenario 3': { + svg: 'part07-fig3', + animate: part07Fig3Scenario3, + autoPlay: false, + showControls: true, + width: 1000, + height: 500, + style: BLOCK, + }, + 'Figure 7: Scenario 4': { + svg: 'part07-fig3', + animate: part07Fig3Scenario4, + autoPlay: false, + showControls: true, + width: 1000, + height: 500, + style: BLOCK, + }, + 'Figure 8: Scenario 5': { + svg: 'part07-fig3', + animate: part07Fig3Scenario5, + autoPlay: false, + showControls: true, + width: 1000, + height: 500, + style: BLOCK, + }, + 'Figure 3a: Ruleset change scenario 1': { + svg: 'part08-fig3', + animate: part08Fig3a, + autoPlay: false, + showControls: true, + width: 800, + height: 400, + style: BLOCK, + }, + 'Figure 3b: Ruleset change scenario 2': { + svg: 'part08-fig3', + animate: part08Fig3b, + autoPlay: false, + showControls: true, + width: 800, + height: 400, + style: BLOCK, + }, + 'Figure 3c: Ruleset change scenario 3': { + svg: 'part08-fig3', + animate: part08Fig3c, + autoPlay: false, + showControls: true, + width: 800, + height: 400, + style: BLOCK, + }, +}; + +/** + * Dev serves `/img/consensus/part07-fig3.svg`; a build emits + * `/assets/part07-fig3-DEk2p0Pr.svg`. The hash cannot be stripped lexically — + * Vite hashes may themselves contain `-` and `_` (`Bexfl0_P`, `DDkSJ-hG`) — + * so match the known name as a prefix instead. + */ +function matchesSvg(src: string, expected: string): boolean { + const file = src.split('/').pop()?.split('?')[0] ?? ''; + const base = file.replace(/\.svg$/i, ''); + return base === expected || base.startsWith(`${expected}-`); +} + +export function getFigureAnimation( + src: string, + alt: string, +): FigureAnimation | undefined { + const spec = FIGURE_ANIMATIONS[alt.trim()]; + if (!spec) return undefined; + return matchesSvg(src, spec.svg) ? spec : undefined; +} diff --git a/src/components/mdx.tsx b/src/components/mdx.tsx index 4f7ac1a..a5c1ecc 100644 --- a/src/components/mdx.tsx +++ b/src/components/mdx.tsx @@ -1,5 +1,6 @@ import defaultMdxComponents from 'fumadocs-ui/mdx'; import type { MDXComponents } from 'mdx/types'; +import { AnimatedFigure } from '@/components/animated-figure'; import { BlogAuthor } from '@/components/blog-author'; import { PgRegressCount } from '@/components/pg-regress-count'; import { YouTubeEmbed } from '@/components/youtube-embed'; @@ -23,6 +24,7 @@ export function getMDXComponents(components?: MDXComponents) { Author, PgRegressCount, YouTubeEmbed, + img: AnimatedFigure, ...components, } satisfies MDXComponents; } diff --git a/src/components/part06Fig1.ts b/src/components/part06Fig1.ts new file mode 100644 index 0000000..f9d5a3f --- /dev/null +++ b/src/components/part06Fig1.ts @@ -0,0 +1,53 @@ +/** + * Animation configuration for Part 6 Figure 1 - Revocation methods + */ + +import { SVGAnimator } from "../lib/svg-animator"; + +const COLORS = SVGAnimator.COLORS; +const DURATION = SVGAnimator.DURATION; + +/** + * Animation sequence for part06-fig1.svg + */ +export const part06Fig1 = (animator: SVGAnimator) => { + // Initial state: Hide specified elements + animator.hideElements([ + "#n1text1", + "#n1text2", + "#n1n3", + "#n1n3text", + "#c6n3", + "#c6n3text", + ]); + + animator.addLabel("start"); + + // Animate c6n3 arrow and c6n3text appearing + animator + .animateArrow("#c6n3", { duration: DURATION.normal }) + .show("#c6n3text", { autoAlpha: 1 }); + + animator.wait(DURATION.normal); + + // Change n3term to "6" with active color + animator + .morphText("#n3term", "6", { duration: DURATION.instant }) + .show("#n3term", { fill: COLORS.active }); + + animator.wait(DURATION.normal); + + // Animate n1n3 arrow and n1n3text appearing + animator + .animateArrow("#n1n3", { duration: DURATION.normal }) + .show("#n1n3text", { autoAlpha: 1 }); + + animator.wait(DURATION.normal); + + // Show n1text1 and n1text2, change n1role to "F" with active color + animator + .show("#n1text1", { autoAlpha: 1 }) + .show("#n1text2", { autoAlpha: 1 }) + .morphText("#n1role", "F", { duration: DURATION.instant }) + .show("#n1role", { fill: COLORS.active }); +}; diff --git a/src/components/part06Fig2Scenario1.ts b/src/components/part06Fig2Scenario1.ts new file mode 100644 index 0000000..94c2fa2 --- /dev/null +++ b/src/components/part06Fig2Scenario1.ts @@ -0,0 +1,68 @@ +/** + * Animation configuration for Part 6 Figure 2 - Scenario 1: no race + */ + +import { SVGAnimator } from "../lib/svg-animator"; + +const COLORS = SVGAnimator.COLORS; +const DURATION = SVGAnimator.DURATION; + +/** + * Animation sequence for part06-fig2.svg - Scenario 1 + */ +export const part06Fig2Scenario1 = (animator: SVGAnimator) => { + // Initial state: Hide all arrows and their text + animator.hideElements([ + "#c6n3", + "#c6n3text", + "#c6n4", + "#c6n4text", + "#c6n5", + "#c6n5text", + "#c6n5fail", + "#c6n5failtext", + "#c7", + "#c7text", + "#c7n2", + "#c7n2text", + "#c7n5", + "#c7n5text", + "#c7n6", + "#c7n6text", + "#n1n3", + "#n1n3text", + ]); + + // Step 1: C6 recruits N3, show descriptions + animator + .animateArrow("#c6n3", { duration: DURATION.normal }) + .show("#c6n3text", { autoAlpha: 1 }) + .changeText("#n3term", "6", COLORS.active, DURATION.fast) + .animateArrow("#n1n3", { duration: DURATION.normal }) + .show("#n1n3text", { autoAlpha: 1 }) + .show("#desc1", { fill: COLORS.active }) + .morphText("#desc1", "* Recruitment of N3 satisfies", { + duration: DURATION.normal, + }) + .show("#desc2", { fill: COLORS.active }) + .morphText("#desc2", " revocation of N1", { duration: DURATION.instant }) + .addLabel("step1"); + + // Step 2: C6 recruits N4 and N5 + animator + .animateArrow("#c6n5", { duration: DURATION.normal }) + .show("#c6n5text", { autoAlpha: 1 }) + .changeText("#n5term", "6", COLORS.active, DURATION.fast) + .animateArrow("#c6n4", { duration: DURATION.normal }) + .show("#c6n4text", { autoAlpha: 1 }) + .changeText("#n4term", "6", COLORS.active, DURATION.fast) + .show("#desc1", { fill: COLORS.inactive }) + .show("#desc2", { fill: COLORS.inactive }) + .show("#desc3", { fill: COLORS.active }) + .morphText("#desc3", "* Recruitment of N4 and N5 satisfies", { + duration: DURATION.normal, + }) + .show("#desc4", { fill: COLORS.active }) + .morphText("#desc4", " candidacy of N4", { duration: DURATION.instant }) + .addLabel("step2"); +}; diff --git a/src/components/part06Fig2Scenario2.ts b/src/components/part06Fig2Scenario2.ts new file mode 100644 index 0000000..f3b23f2 --- /dev/null +++ b/src/components/part06Fig2Scenario2.ts @@ -0,0 +1,82 @@ +/** + * Animation configuration for Part 6 Figure 2 - Scenario 2: newer term steals nodes + */ + +import { SVGAnimator } from "../lib/svg-animator"; + +const COLORS = SVGAnimator.COLORS; +const DURATION = SVGAnimator.DURATION; + +/** + * Animation sequence for part06-fig2.svg - Scenario 2 + */ +export const part06Fig2Scenario2 = (animator: SVGAnimator) => { + // Initial state: Hide all arrows and their text + animator.hideElements([ + "#c6n3", + "#c6n3text", + "#c6n4", + "#c6n4text", + "#c6n5", + "#c6n5text", + "#c6n5fail", + "#c6n5failtext", + "#c7n2", + "#c7n2text", + "#c7n5", + "#c7n5text", + "#c7n6", + "#c7n6text", + "#n1n2", + "#n1n3", + "#n1n3text", + "#np", + "#nptext1", + "#nptext2", + ]); + + // Set initial text before animation steps + animator.setText("#desc0", "Scenario 2: New term steals nodes"); + + // Step 1: C6 recruits N3, C7 recruits N2 + animator + .animateArrow("#c6n3", { duration: DURATION.normal }) + .show("#c6n3text", { autoAlpha: 1 }) + .changeText("#n3term", "6", COLORS.active, DURATION.fast) + .animateArrow("#c7n2", { duration: DURATION.normal }) + .show("#c7n2text", { autoAlpha: 1 }) + .changeText("#n2term", "7", COLORS.blue, DURATION.fast) + .show("#desc1", { fill: COLORS.active }) + .morphText("#desc1", "* C6 and C7 revoke N1 using different nodes", { + duration: DURATION.normal, + }) + .addLabel("step1"); + + // Step 2: C7 recruits N5 and N6 + animator + .show("#c6n3", { autoAlpha: 0 }) + .show("#c6n3text", { autoAlpha: 0 }) + .show("#c7n2", { autoAlpha: 0 }) + .show("#c7n2text", { autoAlpha: 0 }) + .animateArrow("#c7n5", { duration: DURATION.normal }) + .show("#c7n5text", { autoAlpha: 1 }) + .changeText("#n5term", "7", COLORS.blue, DURATION.fast) + .animateArrow("#c7n6", { duration: DURATION.normal }) + .show("#c7n6text", { autoAlpha: 1 }) + .changeText("#n6term", "7", COLORS.blue, DURATION.fast) + .show("#desc1", { fill: COLORS.inactive }) + .show("#desc2", { fill: COLORS.blue }) + .morphText("#desc2", "* C7 recruits N5 and N6") + .addLabel("step2"); + + // Step 3: C6 fails to recruit N5 + animator + .animateArrow("#c6n5fail", { duration: DURATION.normal }) + .show("#c6n5failtext", { autoAlpha: 1 }) + .show("#desc2", { fill: COLORS.inactive }) + .show("#desc3", { fill: COLORS.red }) + .morphText("#desc3", "* C6 cannot recruit N5 because") + .show("#desc4", { fill: COLORS.red }) + .morphText("#desc4", " of its higher term") + .addLabel("step3"); +}; diff --git a/src/components/part06Fig2Scenario3.ts b/src/components/part06Fig2Scenario3.ts new file mode 100644 index 0000000..b808588 --- /dev/null +++ b/src/components/part06Fig2Scenario3.ts @@ -0,0 +1,106 @@ +/** + * Animation configuration for Part 6 Figure 2 - Scenario 3: newer term starts after scenario 1 + */ + +import { SVGAnimator } from "../lib/svg-animator"; + +const COLORS = SVGAnimator.COLORS; +const DURATION = SVGAnimator.DURATION; + +/** + * Animation sequence for part06-fig2.svg - Scenario 3 + */ +export const part06Fig2Scenario3 = (animator: SVGAnimator) => { + // Initial state: Hide all arrows and their text + animator.hideElements([ + "#c6n3", + "#c6n3text", + "#c6n4", + "#c6n4text", + "#c6n5", + "#c6n5text", + "#c6n5fail", + "#c6n5failtext", + "#c7n2", + "#c7n2text", + "#c7n5", + "#c7n5text", + "#c7n6", + "#c7n6text", + "#n1n2", + "#n1n3", + "#n1n3text", + "#np", + "#nptext1", + "#nptext2", + ]); + + // Set initial text before animation steps + animator.setText("#desc0", "Scenario 3: New term starts after scenario 1"); + + // Step 1: Show scenario 1 completion - C6 recruits N3, N4, N5 + // First group: animate arrows simultaneously + animator.group((a: SVGAnimator) => { + a.animateArrow("#c6n3", { duration: DURATION.normal }) + .show("#c6n3text", { autoAlpha: 1 }) + .animateArrow("#c6n5", { duration: DURATION.normal }) + .show("#c6n5text", { autoAlpha: 1 }) + .animateArrow("#c6n4", { duration: DURATION.normal }) + .show("#c6n4text", { autoAlpha: 1 }); + }); + + // Second group: change term numbers + animator.group((a: SVGAnimator) => { + a.changeText("#n3term", "6", COLORS.active, DURATION.fast) + .changeText("#n5term", "6", COLORS.active, DURATION.fast) + .changeText("#n4term", "6", COLORS.active, DURATION.fast); + }); + + animator + .show("#desc1", { fill: COLORS.active }) + .morphText("#desc1", "* C6 recruits N3, N4, N5", { + duration: DURATION.normal, + }); + + animator.addLabel("step1"); + + // Step 2: C7 recruits N2, N5, N6 + // Hide c6 arrows first + animator + .show("#c6n3", { autoAlpha: 0 }) + .show("#c6n3text", { autoAlpha: 0 }) + .show("#c6n4", { autoAlpha: 0 }) + .show("#c6n4text", { autoAlpha: 0 }) + .show("#c6n5", { autoAlpha: 0 }) + .show("#c6n5text", { autoAlpha: 0 }); + + // First group: animate arrows simultaneously + animator.group((a: SVGAnimator) => { + a.animateArrow("#c7n2", { duration: DURATION.normal }) + .show("#c7n2text", { autoAlpha: 1 }) + .animateArrow("#c7n5", { duration: DURATION.normal }) + .show("#c7n5text", { autoAlpha: 1 }) + .animateArrow("#c7n6", { duration: DURATION.normal }) + .show("#c7n6text", { autoAlpha: 1 }); + }); + + // Second group: change term numbers to 7 with blue color + animator.group((a: SVGAnimator) => { + a.changeText("#n2term", "7", COLORS.blue, DURATION.fast) + .changeText("#n5term", "7", COLORS.blue, DURATION.fast) + .changeText("#n6term", "7", COLORS.blue, DURATION.fast); + }); + + animator + .show("#desc1", { fill: COLORS.active }) + .show("#desc2", { fill: COLORS.blue }) + .show("#desc3", { fill: COLORS.blue }) + .morphText("#desc2", "* C7 overrides C6 by recruiting N5 and N6", { + duration: DURATION.normal, + }) + .morphText("#desc3", " that were previously recruited by C6", { + duration: DURATION.instant, + }); + + animator.addLabel("step2"); +}; diff --git a/src/components/part06Fig3.ts b/src/components/part06Fig3.ts new file mode 100644 index 0000000..7ceb8bb --- /dev/null +++ b/src/components/part06Fig3.ts @@ -0,0 +1,222 @@ +/** + * Animation configuration for Part 6 Figure 3 - All possible leaders (NEW VERSION) + */ + +import { SVGAnimator } from "../lib/svg-animator"; + +const DURATION = SVGAnimator.DURATION; + +// Relative offsets for moving elements between sections +// Revocation (top-left) -> Target (right): move right and down +// Calculated from: n1 source (50, 112.738) -> target worked at (825, 435) +// Relative offset: (825-50, 435-112.738) = (775, 322.262) +const REVOCATION_TO_TARGET_OFFSET = { + x: 775, + y: 322.262, +} as const; + +// Candidacy (bottom-left) -> Target (right): move right and UP +// Calculated from: n4n6 source (338.934, 1233.763) -> target works at (1113.934, 813.763) +// Relative offset: (775, -420) +const CANDIDACY_TO_TARGET_OFFSET = { + x: 775, // Same horizontal movement as Revocation->Target + y: -420, // Move up (negative) to reach Target section +} as const; + +// Helper function to calculate target position from source + offset +const addOffset = ( + source: { x: number; y: number }, + offset: { x: number; y: number }, +) => ({ + x: source.x + offset.x, + y: source.y + offset.y, +}); + +// Helper function to extract position from SVG element's transform attribute +const getElementPosition = (selector: string): { x: number; y: number } => { + const element = document.querySelector(selector); + if (!element) { + console.warn(`Element not found: ${selector}`); + return { x: 0, y: 0 }; + } + + const transform = element.getAttribute("transform"); + if (!transform) { + return { x: 0, y: 0 }; + } + + // Extract translate(x, y) from transform attribute + const translateMatch = transform.match(/translate\(([^,\s]+)[\s,]+([^)]+)\)/); + if (translateMatch) { + return { + x: parseFloat(translateMatch[1]), + y: parseFloat(translateMatch[2]), + }; + } + + return { x: 0, y: 0 }; +}; + +/** + * Animation sequence for part06-fig3.svg - All possible leaders (NEW VERSION) + */ +export const part06Fig3 = (animator: SVGAnimator) => { + // Initial state: Hide elements + animator.hideElements(["#blue", "#purple", "#green"]); + + // Step 1: Move elements from Revocation and Candidacy to Target section + + // Get source positions from SVG + const n1Source = getElementPosition("#n1"); + const n4Source = getElementPosition("#n4"); + const n4n6Source = getElementPosition("#n4n6"); + + // Move n1 from Revocation to Target using relative offset + animator.moveTo( + "#n1", + null, + addOffset(n1Source, REVOCATION_TO_TARGET_OFFSET), + { + duration: DURATION.slow, + ease: "power2.inOut", + }, + ); + + // Move n4 from Revocation to Target using relative offset + animator.moveTo( + "#n4", + null, + addOffset(n4Source, REVOCATION_TO_TARGET_OFFSET), + { + duration: DURATION.slow, + ease: "power2.inOut", + }, + ); + + // Move n4n6 from Candidacy to Target using Candidacy-specific offset + animator.moveTo( + "#n4n6", + null, + addOffset(n4n6Source, CANDIDACY_TO_TARGET_OFFSET), + { + duration: DURATION.slow, + ease: "power2.inOut", + }, + ); + + // Show blue text/label + animator.show("#blue", { autoAlpha: 1 }); + + animator.addLabel("step1"); + + // Step 2: Restore step1 elements and move new set of elements to Target + + // First, quickly restore step1 elements to their original positions + animator.group((a: SVGAnimator) => { + a.moveTo("#n1", null, n1Source, { duration: DURATION.fast }); + a.moveTo("#n4", null, n4Source, { duration: DURATION.fast }); + a.moveTo("#n4n6", null, n4n6Source, { duration: DURATION.fast }); + }); + + // Get source positions from SVG for step2 elements + const n3Source = getElementPosition("#n3"); + const n4n5Source = getElementPosition("#n4n5"); + + // Move n3 from Revocation to Target + animator.moveTo( + "#n3", + null, + addOffset(n3Source, REVOCATION_TO_TARGET_OFFSET), + { + duration: DURATION.slow, + ease: "power2.inOut", + }, + ); + + // Move n4 from Revocation to Target (same as step1) + animator.moveTo( + "#n4", + null, + addOffset(n4Source, REVOCATION_TO_TARGET_OFFSET), + { + duration: DURATION.slow, + ease: "power2.inOut", + }, + ); + + // Move n4n5 from Candidacy to Target + animator.moveTo( + "#n4n5", + null, + addOffset(n4n5Source, CANDIDACY_TO_TARGET_OFFSET), + { + duration: DURATION.slow, + ease: "power2.inOut", + }, + ); + + // Show purple text/label + animator.show("#purple", { autoAlpha: 1 }); + + animator.addLabel("step2"); + + // Step 3: Restore step2 elements and move final set of elements to Target + + // First, quickly restore step2 elements to their original positions + animator.group((a: SVGAnimator) => { + a.moveTo("#n3", null, n3Source, { duration: DURATION.fast }); + a.moveTo("#n4", null, n4Source, { duration: DURATION.fast }); + a.moveTo("#n4n5", null, n4n5Source, { duration: DURATION.fast }); + }); + + // Get source positions from SVG for step3 elements + const n5n6Source = getElementPosition("#n5n6"); + const n1n2n3Source = getElementPosition("#n1n2n3"); + + // Move n1 from Revocation to Target + animator.moveTo( + "#n1", + null, + addOffset(n1Source, REVOCATION_TO_TARGET_OFFSET), + { + duration: DURATION.slow, + ease: "power2.inOut", + }, + ); + + // Move n5n6 from Revocation to Target + animator.moveTo( + "#n5n6", + null, + addOffset(n5n6Source, REVOCATION_TO_TARGET_OFFSET), + { + duration: DURATION.slow, + ease: "power2.inOut", + }, + ); + + // Move n1n2n3 from Candidacy to Target + animator.moveTo( + "#n1n2n3", + null, + addOffset(n1n2n3Source, CANDIDACY_TO_TARGET_OFFSET), + { + duration: DURATION.slow, + ease: "power2.inOut", + }, + ); + + // Show green text/label + animator.show("#green", { autoAlpha: 1 }); + + animator.addLabel("step3"); + + // Step 4: Restore all step3 elements to their original positions + animator.group((a: SVGAnimator) => { + a.moveTo("#n1", null, n1Source, { duration: DURATION.fast }); + a.moveTo("#n5n6", null, n5n6Source, { duration: DURATION.fast }); + a.moveTo("#n1n2n3", null, n1n2n3Source, { duration: DURATION.fast }); + }); + + animator.addLabel("step4"); +}; diff --git a/src/components/part07Fig3Raft.ts b/src/components/part07Fig3Raft.ts new file mode 100644 index 0000000..2dfe7a4 --- /dev/null +++ b/src/components/part07Fig3Raft.ts @@ -0,0 +1,143 @@ +/** + * Animation configuration for Part 7 Figure 3 - Raft timeline propagation + */ + +import { SVGAnimator } from "../lib/svg-animator"; + +const COLORS = SVGAnimator.COLORS; +const DURATION = SVGAnimator.DURATION; + +/** + * Animation sequence for part07-fig3.svg - Raft timeline propagation + * + * Available IDs in SVG (nodes N1-N6 with states t2-t5, v2-v5 and arrows between them): + * - n1t3-n1t5, n1v3-n1v5: N1's term/value at different states + * - n2t3-n2t5, n2v3-n2v5: N2's term/value at different states + * - n3t3-n3t5, n3v3-n3v5: N3's term/value at different states + * - n4t3-n4t5, n4v3-n4v5: N4's term/value at different states + * - n5t2-n5t5, n5v2-n5v5: N5's term/value at different states + * - n6t3-n6t5, n6v3-n6v5: N6's term/value at different states + * - Arrows: n1n6, n3n5, n4n1, n4n2, n4n3, n4n5, n4n6, n5n3, n5n4 + * - Arrow text: corresponding *text suffix for each arrow + */ +export const part07Fig3Raft = (animator: SVGAnimator) => { + // Initial state: Hide all elements except N1 slots 3 and 4 + animator.hideElements([ + // N1 elements (slots 3 and 4 are visible initially, only hide slot 5) + "#n1t5", + "#n1v5", + // N2 elements (slot 3 is visible initially) + "#n2t4", + "#n2v4", + "#n2t5", + "#n2v5", + // N3 elements + "#n3t3", + "#n3v3", + "#n3t4", + "#n3v4", + "#n3t5", + "#n3v5", + // N4 elements + "#n4t3", + "#n4v3", + "#n4t4", + "#n4v4", + "#n4t5", + "#n4v5", + // N5 elements + "#n5t2", + "#n5v2", + "#n5t3", + "#n5v3", + "#n5t4", + "#n5v4", + "#n5t5", + "#n5v5", + // N6 elements + "#n6t3", + "#n6v3", + "#n6t4", + "#n6v4", + "#n6t5", + "#n6v5", + // Arrows + "#n1n6", + "#n1n6text", + "#n3n5", + "#n3n5text", + "#n4n1", + "#n4n1text", + "#n4n2", + "#n4n2text", + "#n4n3", + "#n4n3text", + "#n4n5", + "#n4n5text", + "#n4n6", + "#n4n6text", + "#n5n3", + "#n5n3text", + "#n5n4", + "#n5n4text", + // Quorums + "#quorum1", + "#quorum2", + "#quorum3", + // Descriptions + "#desc1", + "#desc2", + "#desc3", + "#desc4", + "#desc5", + ]); + + + // Step 1: N1 propagates C (with term 5) to N6 + animator + .animateArrow("#n1n6", { duration: DURATION.normal }) + .show("#n1n6text", { autoAlpha: 1 }) + .wait(DURATION.fast) + .show("#n6t3", { autoAlpha: 1 }) + .show("#n6v3", { autoAlpha: 1 }) + .morphText("#desc1", "* Step 1: Append 5-C") + .show("#desc1", { autoAlpha: 1, fill: COLORS.blue, attr: { dx: -80, dy: -60 } }); + + animator.addLabel("step1"); + + // Step 2: N1 propagates D (with term 5) to N6 + animator + .unanimateArrow("#n1n6", { duration: DURATION.instant }) + .set("#n1n6text", { autoAlpha: 0 }) + .morphText("#n1n6text", "Append 5-D") + .animateArrow("#n1n6", { duration: DURATION.normal }) + .show("#n1n6text", { autoAlpha: 1 }) + .wait(DURATION.fast) + .show("#n6t4", { autoAlpha: 1 }) + .show("#n6v4", { autoAlpha: 1 }) + .morphText("#desc2", "* Step 2: Append 5-D") + .show("#desc2", { autoAlpha: 1, fill: COLORS.blue, attr: { dx: -80, dy: -60 } }); + + animator.addLabel("step2"); + + // Step 3: N1 creates new request with term 7 and replicates to N6 + animator + .wait(DURATION.fast) + .show("#n1t5", { autoAlpha: 1 }) + .show("#n1v5", { autoAlpha: 1 }) + .unanimateArrow("#n1n6", { duration: DURATION.instant }) + .set("#n1n6text", { autoAlpha: 0 }) + .morphText("#n1n6text", "Append 7-ok") + .animateArrow("#n1n6", { duration: DURATION.normal }) + .show("#n1n6text", { autoAlpha: 1 }) + .wait(DURATION.fast) + .show("#n6t5", { autoAlpha: 1 }) + .show("#n6v5", { autoAlpha: 1 }) + .morphText("#desc3", "* Step 3: Append 7-ok to N1") + .show("#desc3", { autoAlpha: 1, fill: COLORS.blue, attr: { dx: -80, dy: -60 } }) + .morphText("#desc4", " and transmit to N7") + .show("#desc4", { autoAlpha: 1, fill: COLORS.blue, attr: { dx: -80, dy: -60 } }); + + animator.addLabel("step3"); + +}; diff --git a/src/components/part07Fig3Scenario2.ts b/src/components/part07Fig3Scenario2.ts new file mode 100644 index 0000000..ed2ef9e --- /dev/null +++ b/src/components/part07Fig3Scenario2.ts @@ -0,0 +1,145 @@ +/** + * Animation configuration for Part 7 Figure 3 - Scenario 2 + */ + +import { SVGAnimator } from "../lib/svg-animator"; + +const COLORS = SVGAnimator.COLORS; +const DURATION = SVGAnimator.DURATION; + +/** + * Animation sequence for part07-fig3.svg - Scenario 2 + * C6 recruits N3, N4 and N5 for revocation and candidacy + * + * Available IDs in SVG (nodes N1-N6 with states t2-t5, v2-v5 and arrows between them): + * - n1t3-n1t5, n1v3-n1v5: N1's term/value at different states + * - n2t3-n2t5, n2v3-n2v5: N2's term/value at different states + * - n3t3-n3t5, n3v3-n3v5: N3's term/value at different states + * - n4t3-n4t5, n4v3-n4v5: N4's term/value at different states + * - n5t2-n5t5, n5v2-n5v5: N5's term/value at different states + * - n6t3-n6t5, n6v3-n6v5: N6's term/value at different states + * - Arrows: n1n6, n3n5, n4n1, n4n2, n4n3, n4n5, n4n6, n5n3, n5n4 + * - Arrow text: corresponding *text suffix for each arrow + */ +export const part07Fig3Scenario2 = (animator: SVGAnimator) => { + // Initial state: Hide all elements except N1 slots 3 and 4 + animator.hideElements([ + // N1 elements (slots 3 and 4 are visible initially, only hide slot 5) + "#n1t5", + "#n1v5", + // N2 elements (slot 3 is visible initially) + "#n2t4", + "#n2v4", + "#n2t5", + "#n2v5", + // N3 elements + "#n3t3", + "#n3v3", + "#n3t4", + "#n3v4", + "#n3t5", + "#n3v5", + // N4 elements + "#n4t3", + "#n4v3", + "#n4t4", + "#n4v4", + "#n4t5", + "#n4v5", + // N5 elements + "#n5t2", + "#n5v2", + "#n5t3", + "#n5v3", + "#n5t4", + "#n5v4", + "#n5t5", + "#n5v5", + // N6 elements + "#n6t3", + "#n6v3", + "#n6t4", + "#n6v4", + "#n6t5", + "#n6v5", + // Arrows + "#n1n6", + "#n1n6text", + "#n3n5", + "#n3n5text", + "#n4n1", + "#n4n1text", + "#n4n2", + "#n4n2text", + "#n4n3", + "#n4n3text", + "#n4n5", + "#n4n5text", + "#n4n6", + "#n4n6text", + "#n5n3", + "#n5n3text", + "#n5n4", + "#n5n4text", + // Quorums + "#quorum1", + "#quorum2", + "#quorum3", + // Descriptions + "#desc1", + "#desc2", + "#desc3", + "#desc4", + "#desc5", + ]); + + + // Step 1: C6 recruits N3, N4 and N5 + animator + .show("#n3", { fill: COLORS.active }) + .show("#n4", { fill: COLORS.active }) + .show("#n5", { fill: COLORS.active }) + .show("#quorum1", { autoAlpha: 1 }) + .morphText("#desc1", "* C6 recruits N3, N4 and N5") + .show("#desc1", { autoAlpha: 1, fill: COLORS.active, attr: { dx: -300, dy: 450 } }); + + animator.addLabel("step1"); + + // Step 2: N3 appends 5-B to N5 + animator + .morphText("#n3n5text", "Append 5-B") + .animateArrow("#n3n5", { duration: DURATION.normal }) + .show("#n3n5text", { autoAlpha: 1 }) + .wait(DURATION.fast) + .show("#n5t2", { autoAlpha: 1 }) + .show("#n5v2", { autoAlpha: 1 }) + .morphText("#desc2", "* N3 appends 5-B to N5") + .show("#desc2", { autoAlpha: 1, fill: COLORS.active, attr: { dx: -300, dy: 450 } }); + + animator.addLabel("step2"); + + // Step 3: N3 appends 6-ok to itself and appends to N5 + animator + .wait(DURATION.fast) + .show("#n3t3", { autoAlpha: 1 }) + .show("#n3v3", { autoAlpha: 1 }) + .unanimateArrow("#n3n5", { duration: DURATION.instant }) + .set("#n3n5text", { autoAlpha: 0 }) + .morphText("#n3n5text", "Append 6-ok") + .animateArrow("#n3n5", { duration: DURATION.normal }) + .show("#n3n5text", { autoAlpha: 1 }) + .wait(DURATION.fast) + .show("#n5t3", { autoAlpha: 1 }) + .show("#n5v3", { autoAlpha: 1 }) + .morphText("#desc3", "* N3 appends 6-ok to itself and N5") + .show("#desc3", { autoAlpha: 1, fill: COLORS.active, attr: { dx: -300, dy: 450 } }); + + animator.addLabel("step3"); + + // Step 4: C6 crashes + animator + .morphText("#desc4", "* C6 crashes") + .show("#desc4", { autoAlpha: 1, fill: COLORS.red, attr: { dx: -300, dy: 450 } }); + + animator.addLabel("step4"); +}; diff --git a/src/components/part07Fig3Scenario3.ts b/src/components/part07Fig3Scenario3.ts new file mode 100644 index 0000000..f490e80 --- /dev/null +++ b/src/components/part07Fig3Scenario3.ts @@ -0,0 +1,154 @@ +/** + * Animation configuration for Part 7 Figure 3 - Scenario 3 + */ + +import { SVGAnimator } from "../lib/svg-animator"; + +const COLORS = SVGAnimator.COLORS; +const DURATION = SVGAnimator.DURATION; + +/** + * Animation sequence for part07-fig3.svg - Scenario 3 + * C7 recruits N1, N4 and N6, propagates N1 to N6, then fails + * + * Available IDs in SVG (nodes N1-N6 with states t2-t5, v2-v5 and arrows between them): + * - n1t3-n1t5, n1v3-n1v5: N1's term/value at different states + * - n2t3-n2t5, n2v3-n2v5: N2's term/value at different states + * - n3t3-n3t5, n3v3-n3v5: N3's term/value at different states + * - n4t3-n4t5, n4v3-n4v5: N4's term/value at different states + * - n5t2-n5t5, n5v2-n5v5: N5's term/value at different states + * - n6t3-n6t5, n6v3-n6v5: N6's term/value at different states + * - Arrows: n1n6, n3n5, n4n1, n4n2, n4n3, n4n5, n4n6, n5n3, n5n4 + * - Arrow text: corresponding *text suffix for each arrow + */ +export const part07Fig3Scenario3 = (animator: SVGAnimator) => { + // Initial state: Ending state of Scenario 2 (after C6 crashes) + // N1 has slots 3 and 4, N2 has slot 3, N3 has slot 3 (6-ok), N5 has slots 2 and 3 (5-B and 6-ok) + animator.hideElements([ + // N1 elements (slots 3 and 4 are visible initially, only hide slot 5) + "#n1t5", + "#n1v5", + // N2 elements (slot 3 is visible initially) + "#n2t4", + "#n2v4", + "#n2t5", + "#n2v5", + // N3 elements (slot 3 is visible - 6-ok from Scenario 2) + "#n3t4", + "#n3v4", + "#n3t5", + "#n3v5", + // N4 elements (all hidden) + "#n4t3", + "#n4v3", + "#n4t4", + "#n4v4", + "#n4t5", + "#n4v5", + // N5 elements (slots 2 and 3 are visible - 5-B and 6-ok from Scenario 2) + "#n5t4", + "#n5v4", + "#n5t5", + "#n5v5", + // N6 elements + "#n6t3", + "#n6v3", + "#n6t4", + "#n6v4", + "#n6t5", + "#n6v5", + // Arrows + "#n1n6", + "#n1n6text", + "#n3n5", + "#n3n5text", + "#n4n1", + "#n4n1text", + "#n4n2", + "#n4n2text", + "#n4n3", + "#n4n3text", + "#n4n5", + "#n4n5text", + "#n4n6", + "#n4n6text", + "#n5n3", + "#n5n3text", + "#n5n4", + "#n5n4text", + // Quorums + "#quorum1", + "#quorum2", + "#quorum3", + // Descriptions + "#desc1", + "#desc2", + "#desc3", + "#desc4", + "#desc5", + ]); + + // Step 1: C7 recruits N1, N4 and N6 + animator + .show("#n1", { fill: COLORS.blue }) + .show("#n4", { fill: COLORS.blue }) + .show("#n6", { fill: COLORS.blue }) + .show("#quorum2", { autoAlpha: 1 }) + .morphText("#desc1", "* C7 recruits N1, N4 and N6") + .show("#desc1", { autoAlpha: 1, fill: COLORS.blue, attr: { dx: -200, dy: -60 } }); + + animator.addLabel("step1"); + + // Step 2: N1 propagates C (with term 5) to N6 + animator + .morphText("#n1n6text", "Append 5-C") + .animateArrow("#n1n6", { duration: DURATION.normal }) + .show("#n1n6text", { autoAlpha: 1 }) + .wait(DURATION.fast) + .show("#n6t3", { autoAlpha: 1 }) + .show("#n6v3", { autoAlpha: 1 }) + .morphText("#desc2", "* Step 2: Append 5-C") + .show("#desc2", { autoAlpha: 1, fill: COLORS.blue, attr: { dx: -200, dy: -60 } }); + + animator.addLabel("step2"); + + // Step 3: N1 propagates D (with term 5) to N6 + animator + .unanimateArrow("#n1n6", { duration: DURATION.instant }) + .set("#n1n6text", { autoAlpha: 0 }) + .morphText("#n1n6text", "Append 5-D") + .animateArrow("#n1n6", { duration: DURATION.normal }) + .show("#n1n6text", { autoAlpha: 1 }) + .wait(DURATION.fast) + .show("#n6t4", { autoAlpha: 1 }) + .show("#n6v4", { autoAlpha: 1 }) + .morphText("#desc3", "* Step 3: Append 5-D") + .show("#desc3", { autoAlpha: 1, fill: COLORS.blue, attr: { dx: -200, dy: -60 } }); + + animator.addLabel("step3"); + + // Step 4: N1 creates new request with term 7 and replicates to N6 + animator + .wait(DURATION.fast) + .show("#n1t5", { autoAlpha: 1 }) + .show("#n1v5", { autoAlpha: 1 }) + .unanimateArrow("#n1n6", { duration: DURATION.instant }) + .set("#n1n6text", { autoAlpha: 0 }) + .morphText("#n1n6text", "Append 7-ok") + .animateArrow("#n1n6", { duration: DURATION.normal }) + .show("#n1n6text", { autoAlpha: 1 }) + .wait(DURATION.fast) + .show("#n6t5", { autoAlpha: 1 }) + .show("#n6v5", { autoAlpha: 1 }) + .morphText("#desc4", "* Step 4: Append 7-ok to N1 and transmit") + .show("#desc4", { autoAlpha: 1, fill: COLORS.blue, attr: { dx: -200, dy: -60 } }); + + animator.addLabel("step4"); + + // Step 5: C7 crashes + animator + .morphText("#desc5", "* C7 crashes") + .show("#desc5", { autoAlpha: 1, fill: COLORS.red, attr: { dx: -200, dy: -60 } }); + + animator.addLabel("step5"); +}; diff --git a/src/components/part07Fig3Scenario4.ts b/src/components/part07Fig3Scenario4.ts new file mode 100644 index 0000000..b3b5ba4 --- /dev/null +++ b/src/components/part07Fig3Scenario4.ts @@ -0,0 +1,144 @@ +/** + * Animation configuration for Part 7 Figure 3 - Scenario 4 + */ + +import { SVGAnimator } from "../lib/svg-animator"; + +const COLORS = SVGAnimator.COLORS; +const DURATION = SVGAnimator.DURATION; + +/** + * Animation sequence for part07-fig3.svg - Scenario 4 + * Initial state matches ending state of Scenario 3 + * + * Available IDs in SVG (nodes N1-N6 with states t2-t5, v2-v5 and arrows between them): + * - n1t3-n1t5, n1v3-n1v5: N1's term/value at different states + * - n2t3-n2t5, n2v3-n2v5: N2's term/value at different states + * - n3t3-n3t5, n3v3-n3v5: N3's term/value at different states + * - n4t3-n4t5, n4v3-n4v5: N4's term/value at different states + * - n5t2-n5t5, n5v2-n5v5: N5's term/value at different states + * - n6t3-n6t5, n6v3-n6v5: N6's term/value at different states + * - Arrows: n1n6, n3n5, n4n1, n4n2, n4n3, n4n5, n4n6, n5n3, n5n4 + * - Arrow text: corresponding *text suffix for each arrow + */ +export const part07Fig3Scenario4 = (animator: SVGAnimator) => { + // Initial state: Ending state of Scenario 3 (after C7 propagates to N6) + // N1 has slots 3, 4, 5 (C, D, 7-ok), N2 has slot 3, N3 has slot 3 (6-ok), + // N5 has slots 2 and 3 (5-B and 6-ok), N6 has slots 3, 4, 5 (C, D, 7-ok) + animator.hideElements([ + // N1 elements (slots 3, 4, and 5 are visible initially) + // N2 elements (slot 3 is visible initially) + "#n2t4", + "#n2v4", + "#n2t5", + "#n2v5", + // N3 elements (slot 3 is visible - 6-ok from Scenario 2) + "#n3t4", + "#n3v4", + "#n3t5", + "#n3v5", + // N4 elements (all hidden) + "#n4t3", + "#n4v3", + "#n4t4", + "#n4v4", + "#n4t5", + "#n4v5", + // N5 elements (slots 2 and 3 are visible - 5-B and 6-ok from Scenario 2) + "#n5t4", + "#n5v4", + "#n5t5", + "#n5v5", + // N6 elements (slots 3, 4, and 5 are visible - C, D, 7-ok from Scenario 3) + // Arrows + "#n1n6", + "#n1n6text", + "#n3n5", + "#n3n5text", + "#n4n1", + "#n4n1text", + "#n4n2", + "#n4n2text", + "#n4n3", + "#n4n3text", + "#n4n5", + "#n4n5text", + "#n4n6", + "#n4n6text", + "#n5n3", + "#n5n3text", + "#n5n4", + "#n5n4text", + // Quorums + "#quorum1", + "#quorum2", + "#quorum3", + // Descriptions + "#desc1", + "#desc2", + "#desc3", + "#desc4", + "#desc5", + ]); + + // Step 1: C8 recruits N3, N4 and N5 + animator + .show("#n3", { fill: COLORS.orange }) + .show("#n4", { fill: COLORS.orange }) + .show("#n5", { fill: COLORS.orange }) + .show("#quorum1", { autoAlpha: 1, stroke: COLORS.orange }) + .morphText("#desc1", "* C8 recruits N3, N4 and N5") + .show("#desc1", { autoAlpha: 1, fill: COLORS.orange, attr: { dx: -300, dy: 450 } }); + + animator.addLabel("step1"); + + // Step 2: N5 appends 6-ok to N4 + animator + .morphText("#n5n4text", "Append 6-ok") + .animateArrow("#n5n4", { duration: DURATION.normal }) + .show("#n5n4text", { autoAlpha: 1 }) + .wait(DURATION.fast) + .show("#n4t3", { autoAlpha: 1 }) + .show("#n4v3", { autoAlpha: 1 }) + .morphText("#desc2", "* N5 appends 6-ok to N4") + .show("#desc2", { autoAlpha: 1, fill: COLORS.orange, attr: { dx: -300, dy: 450 } }); + + animator.addLabel("step2"); + + // Step 3: N5 applies and propagates 8-ok to N3 and N4 + animator + .wait(DURATION.fast) + .show("#n5t4", { autoAlpha: 1 }) + .show("#n5v4", { autoAlpha: 1 }) + .unanimateArrow("#n5n4", { duration: DURATION.instant }) + .set("#n5n4text", { autoAlpha: 0 }) + .group(() => { + animator + .morphText("#n5n3text", "Append 8-ok") + .animateArrow("#n5n3", { duration: DURATION.normal }) + .show("#n5n3text", { autoAlpha: 1 }); + animator + .morphText("#n5n4text", "Append 8-ok") + .animateArrow("#n5n4", { duration: DURATION.normal }) + .show("#n5n4text", { autoAlpha: 1 }); + }) + .wait(DURATION.fast) + .show("#n3t4", { autoAlpha: 1 }) + .show("#n3v4", { autoAlpha: 1 }) + .show("#n4t4", { autoAlpha: 1 }) + .show("#n4v4", { autoAlpha: 1 }) + .morphText("#desc3", "* N5 appends 8-ok to itself") + .show("#desc3", { autoAlpha: 1, fill: COLORS.orange, attr: { dx: -300, dy: 450 } }) + .morphText("#desc4", " and propagates to N3 and N4") + .show("#desc4", { autoAlpha: 1, fill: COLORS.orange, attr: { dx: -300, dy: 450 } }); + + animator.addLabel("step3"); + + // Step 4: 8-ok reaches durability but C8 crashes + animator + .wait(DURATION.fast) + .morphText("#desc5", "* Durability reached, but C8 crashes") + .show("#desc5", { autoAlpha: 1, fill: COLORS.red, attr: { dx: -300, dy: 450 } }); + + animator.addLabel("step4"); +}; diff --git a/src/components/part07Fig3Scenario5.ts b/src/components/part07Fig3Scenario5.ts new file mode 100644 index 0000000..f8fe52d --- /dev/null +++ b/src/components/part07Fig3Scenario5.ts @@ -0,0 +1,218 @@ +/** + * Animation configuration for Part 7 Figure 3 - Scenario 5 + */ + +import { SVGAnimator } from "../lib/svg-animator"; + +const COLORS = SVGAnimator.COLORS; +const DURATION = SVGAnimator.DURATION; + +/** + * Animation sequence for part07-fig3.svg - Scenario 5 + * Initial state matches ending state of Scenario 4 + * + * Available IDs in SVG (nodes N1-N6 with states t2-t5, v2-v5 and arrows between them): + * - n1t3-n1t5, n1v3-n1v5: N1's term/value at different states + * - n2t3-n2t5, n2v3-n2v5: N2's term/value at different states + * - n3t3-n3t5, n3v3-n3v5: N3's term/value at different states + * - n4t3-n4t5, n4v3-n4v5: N4's term/value at different states + * - n5t2-n5t5, n5v2-n5v5: N5's term/value at different states + * - n6t3-n6t5, n6v3-n6v5: N6's term/value at different states + * - Arrows: n1n6, n3n5, n4n1, n4n2, n4n3, n4n5, n4n6, n5n3, n5n4 + * - Arrow text: corresponding *text suffix for each arrow + */ +export const part07Fig3Scenario5 = (animator: SVGAnimator) => { + // Initial state: Ending state of Scenario 4 (after C8 crashes with 8-ok durable) + // N1 has slots 3, 4, 5 (C, D, 7-ok), N2 has slot 3 (C), + // N3 has slots 3, 4 (6-ok, 8-ok), N4 has slots 3, 4 (6-ok, 8-ok), + // N5 has slots 2, 3, 4 (5-B, 6-ok, 8-ok), N6 has slots 3, 4, 5 (C, D, 7-ok) + animator.hideElements([ + // N1 elements (slots 3, 4, and 5 are visible initially) + // N2 elements (slot 3 is visible initially) + "#n2t4", + "#n2v4", + "#n2t5", + "#n2v5", + // N3 elements (slots 3 and 4 are visible - 6-ok and 8-ok from Scenario 4) + "#n3t5", + "#n3v5", + // N4 elements (slots 3 and 4 are visible - 6-ok and 8-ok from Scenario 4) + "#n4t5", + "#n4v5", + // N5 elements (slots 2, 3, and 4 are visible - 5-B, 6-ok, 8-ok from Scenario 4) + "#n5t5", + "#n5v5", + // N6 elements (slots 3, 4, and 5 are visible - C, D, 7-ok from Scenario 4) + // Arrows + "#n1n6", + "#n1n6text", + "#n3n5", + "#n3n5text", + "#n4n1", + "#n4n1text", + "#n4n2", + "#n4n2text", + "#n4n3", + "#n4n3text", + "#n4n5", + "#n4n5text", + "#n4n6", + "#n4n6text", + "#n5n3", + "#n5n3text", + "#n5n4", + "#n5n4text", + // Quorums + "#quorum1", + "#quorum2", + "#quorum3", + // Descriptions + "#desc1", + "#desc2", + "#desc3", + "#desc4", + "#desc5", + ]); + + // Step 1: C9 discovers all nodes + animator + .show("#n1", { fill: COLORS.purple }) + .show("#n2", { fill: COLORS.purple }) + .show("#n3", { fill: COLORS.purple }) + .show("#n4", { fill: COLORS.purple }) + .show("#n5", { fill: COLORS.purple }) + .show("#n6", { fill: COLORS.purple }) + .show("#quorum3", { autoAlpha: 1 }) + .morphText("#desc1", "* C9 discovers all nodes") + .show("#desc1", { autoAlpha: 1, fill: COLORS.purple, attr: { dx: -90, dy: -120 } }); + + animator.addLabel("step1"); + + // Step 2: N1 appends 6-ok to N1, N2 and N6 + animator + .group(() => { + animator + .morphText("#n4n1text", "Append 6-ok") + .animateArrow("#n4n1", { duration: DURATION.normal }) + .morphText("#n4n2text", "Append 6-ok") + .animateArrow("#n4n2", { duration: DURATION.normal }) + .morphText("#n4n6text", "Append 6-ok") + .animateArrow("#n4n6", { duration: DURATION.normal }); + }) + .show("#n4n1text", { autoAlpha: 1 }) + .show("#n4n2text", { autoAlpha: 1 }) + .show("#n4n6text", { autoAlpha: 1 }) + .changeText("#n1t3", "", COLORS.active, DURATION.instant) + .changeText("#n1v3", "", COLORS.active, DURATION.instant) + .changeText("#n1t4", "", COLORS.active, DURATION.instant) + .changeText("#n1v4", "", COLORS.active, DURATION.instant) + .changeText("#n1t5", "", COLORS.active, DURATION.instant) + .changeText("#n1v5", "", COLORS.active, DURATION.instant) + .changeText("#n2t3", "", COLORS.active, DURATION.instant) + .changeText("#n2v3", "", COLORS.active, DURATION.instant) + .changeText("#n6t3", "", COLORS.active, DURATION.instant) + .changeText("#n6v3", "", COLORS.active, DURATION.instant) + .changeText("#n6t4", "", COLORS.active, DURATION.instant) + .changeText("#n6v4", "", COLORS.active, DURATION.instant) + .changeText("#n6t5", "", COLORS.active, DURATION.instant) + .changeText("#n6v5", "", COLORS.active, DURATION.instant) + .group(() => { + animator + .changeText("#n1t3", "6", COLORS.active, DURATION.fast) + .changeText("#n1v3", "ok", COLORS.active, DURATION.fast) + .changeText("#n2t3", "6", COLORS.active, DURATION.fast) + .changeText("#n2v3", "ok", COLORS.active, DURATION.fast) + .changeText("#n6t3", "6", COLORS.active, DURATION.fast) + .changeText("#n6v3", "ok", COLORS.active, DURATION.fast); + }) + .morphText("#desc2", "* N4 appends 6-ok to N1, N2 and N6") + .show("#desc2", { autoAlpha: 1, fill: COLORS.purple, attr: { dx: -90, dy: -120 } }) + .morphText("#desc3", " truncating logs as needed") + .show("#desc3", { autoAlpha: 1, fill: COLORS.purple, attr: { dx: -90, dy: -120 } }); + + animator.addLabel("step2"); + + // Step 3: N1 appends 8-ok to N1, N2 and N6 + animator + .unanimateArrow("#n4n1", { duration: DURATION.instant }) + .unanimateArrow("#n4n2", { duration: DURATION.instant }) + .unanimateArrow("#n4n6", { duration: DURATION.instant }) + .group(() => { + animator + .morphText("#n4n1text", "Append 8-ok") + .animateArrow("#n4n1", { duration: DURATION.normal }) + .morphText("#n4n2text", "Append 8-ok") + .animateArrow("#n4n2", { duration: DURATION.normal }) + .morphText("#n4n6text", "Append 8-ok") + .animateArrow("#n4n6", { duration: DURATION.normal }); + }) + .show("#n4n1text", { autoAlpha: 1 }) + .show("#n4n2text", { autoAlpha: 1 }) + .show("#n4n6text", { autoAlpha: 1 }) + .group(() => { + animator + .changeText("#n1t4", "8", COLORS.orange, DURATION.fast) + .changeText("#n1v4", "ok", COLORS.orange, DURATION.fast) + .changeText("#n2t4", "8", COLORS.orange, DURATION.fast) + .show("#n2t4", { autoAlpha: 1 }) + .changeText("#n2v4", "ok", COLORS.orange, DURATION.fast) + .show("#n2v4", { autoAlpha: 1 }) + .changeText("#n6t4", "8", COLORS.orange, DURATION.fast) + .changeText("#n6v4", "ok", COLORS.orange, DURATION.fast); + }) + .morphText("#desc4", "* N4 appends 8-ok to N1, N2 and N6") + .show("#desc4", { autoAlpha: 1, fill: COLORS.purple, attr: { dx: -90, dy: -120 } }); + + animator.addLabel("step3"); + + // Step 3: N4 appends 9-ok to itself and transmits to all + animator + .wait(DURATION.fast) + .show("#n4t5", { autoAlpha: 1 }) + .show("#n4v5", { autoAlpha: 1 }) + .unanimateArrow("#n4n1", { duration: DURATION.instant }) + .set("#n4n1text", { autoAlpha: 0 }) + .unanimateArrow("#n4n2", { duration: DURATION.instant }) + .set("#n4n2text", { autoAlpha: 0 }) + .unanimateArrow("#n4n5", { duration: DURATION.instant }) + .set("#n4n5text", { autoAlpha: 0 }) + .unanimateArrow("#n4n6", { duration: DURATION.instant }) + .set("#n4n6text", { autoAlpha: 0 }) + .group(() => { + animator + .morphText("#n4n1text", "Append 9-ok") + .animateArrow("#n4n1", { duration: DURATION.normal }) + .show("#n4n1text", { autoAlpha: 1 }); + animator + .morphText("#n4n2text", "Append 9-ok") + .animateArrow("#n4n2", { duration: DURATION.normal }) + .show("#n4n2text", { autoAlpha: 1 }); + animator + .morphText("#n4n3text", "Append 9-ok") + .animateArrow("#n4n3", { duration: DURATION.normal }) + .show("#n4n3text", { autoAlpha: 1 }); + animator + .morphText("#n4n5text", "Append 9-ok") + .animateArrow("#n4n5", { duration: DURATION.normal }) + .show("#n4n5text", { autoAlpha: 1 }); + animator + .morphText("#n4n6text", "Append 9-ok") + .animateArrow("#n4n6", { duration: DURATION.normal }) + .show("#n4n6text", { autoAlpha: 1 }); + }) + .wait(DURATION.fast) + .changeText("#n1t5", "9", COLORS.purple, DURATION.instant) + .changeText("#n1v5", "ok", COLORS.purple, DURATION.instant) + .show("#n2t5", { autoAlpha: 1 }) + .show("#n2v5", { autoAlpha: 1 }) + .show("#n3t5", { autoAlpha: 1 }) + .show("#n3v5", { autoAlpha: 1 }) + .show("#n5t5", { autoAlpha: 1 }) + .show("#n5v5", { autoAlpha: 1 }) + .changeText("#n6t5", "9", COLORS.purple, DURATION.instant) + .changeText("#n6v5", "ok", COLORS.purple, DURATION.instant) + .morphText("#desc5", "* N4 appends 9-ok to itself and transmits") + .show("#desc5", { autoAlpha: 1, fill: COLORS.purple, attr: { dx: -90, dy: -120 } }); + + animator.addLabel("step4"); +}; diff --git a/src/components/part08Fig3a.ts b/src/components/part08Fig3a.ts new file mode 100644 index 0000000..c48adb3 --- /dev/null +++ b/src/components/part08Fig3a.ts @@ -0,0 +1,113 @@ +/** + * Animation configuration for Part 8 Figure 3a - Ruleset change scenario 1 + * C7 recruits N2 and discovers a more progressed timeline + */ + +import { SVGAnimator } from "../lib/svg-animator"; + +const COLORS = SVGAnimator.COLORS; +const DURATION = SVGAnimator.DURATION; + +/** + * Animation sequence for part08-fig3.svg - Scenario 1 + * Shows C7 recruiting N2, discovering progressed timeline, + * and delegating leadership back to N1 + */ +export const part08Fig3a = (animator: SVGAnimator) => { + // Initial state: Hide all animated elements except c, ctext, and node rules + animator.hideElements([ + "#cn1", + "#cn1text", + "#cn2", + "#cn2text", + "#cn3", + "#cn3text", + "#desc1", + "#desc2", + "#desc3", + "#desc4", + "#desc5", + "#n1applied", + "#n1t2", + "#n1t3", + "#n1t4", + "#n1v2", + "#n1v3", + "#n1v4", + "#n2applied", + "#n2t2", + "#n2t3", + "#n2t4", + "#n2v2", + "#n2v3", + "#n2v4", + "#n3applied", + "#n3t2", + "#n3t3", + "#n3t4", + "#n3v2", + "#n3v2hi", + "#n3v3", + "#n3v4", + ]); + + // Step 1: C revokes N1 leadership (orange) + animator + .show("#c", { stroke: COLORS.orange }) + .show("#ctext", { fill: COLORS.orange }) + .show("#cn1", { stroke: COLORS.orange }) + .animateArrow("#cn1", { duration: DURATION.normal }) + .morphText("#cn1text", "Revoke", { duration: DURATION.instant }) + .show("#cn1text", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#desc1", "* C6 revokes N1's leadership", { + duration: DURATION.instant, + }) + .show("#desc1", { autoAlpha: 1, fill: COLORS.orange }) + .wait(DURATION.pause); + + animator.addLabel("step1"); + + // Step 2: C6 appends ruleset change 6-rs2 to logs of N1, N2, N3 + animator + .unanimateArrow("#cn1", { duration: DURATION.instant }) + .show("#desc1", { fill: COLORS.inactive }) + .show("#cn1text", { autoAlpha: 0 }) + .show("#cn2", { stroke: COLORS.orange }) + .show("#cn3", { stroke: COLORS.orange }) + .group(() => { + animator.animateArrow("#cn1", { duration: DURATION.normal }); + animator.animateArrow("#cn2", { duration: DURATION.normal }); + animator.animateArrow("#cn3", { duration: DURATION.normal }); + }) + .morphText("#cn1text", "rs2", { duration: DURATION.instant }) + .show("#cn1text", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#cn2text", "rs2", { duration: DURATION.instant }) + .show("#cn2text", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#cn3text", "rs2", { duration: DURATION.instant }) + .show("#cn3text", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n1t2", "6", { duration: DURATION.instant }) + .show("#n1t2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n1v2", "rs2", { duration: DURATION.instant }) + .show("#n1v2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n2t2", "6", { duration: DURATION.instant }) + .show("#n2t2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n2v2", "rs2", { duration: DURATION.instant }) + .show("#n2v2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n3t2", "6", { duration: DURATION.instant }) + .show("#n3t2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n3v2", "rs2", { duration: DURATION.instant }) + .show("#n3v2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n1term", "6", { duration: DURATION.instant }) + .show("#n1term", { fill: COLORS.orange }) + .morphText("#n2term", "6", { duration: DURATION.instant }) + .show("#n2term", { fill: COLORS.orange }) + .morphText("#n3term", "6", { duration: DURATION.instant }) + .show("#n3term", { fill: COLORS.orange }) + .morphText("#desc2", "* C6 appends 6-rs2 to N1, N2, N3", { + duration: DURATION.instant, + }) + .show("#desc2", { autoAlpha: 1, fill: COLORS.orange }) + .wait(DURATION.pause); + + animator.addLabel("step2"); +}; diff --git a/src/components/part08Fig3b.ts b/src/components/part08Fig3b.ts new file mode 100644 index 0000000..7bb0f3c --- /dev/null +++ b/src/components/part08Fig3b.ts @@ -0,0 +1,338 @@ +/** + * Animation configuration for Part 8 Figure 3b - Ruleset change scenario 2 + * C7 recruits N2 and discovers timelines have not progressed + */ + +import { SVGAnimator } from "../lib/svg-animator"; + +const COLORS = SVGAnimator.COLORS; +const DURATION = SVGAnimator.DURATION; + +/** + * Animation sequence for part08-fig3.svg - Scenario 2 + * Shows C7 recruiting N2, discovering no progress, + * and propagating using combined ruleset + */ +export const part08Fig3b = (animator: SVGAnimator) => { + // Hide elements that will be animated in this scenario + animator.hideElements([ + "#cn1", + "#cn1text", + "#cn2", + "#cn2text", + "#cn3", + "#cn3text", + "#desc1", + "#desc2", + "#desc3", + "#desc4", + "#desc5", + "#n1applied", + "#n1t3", + "#n1t4", + "#n1v3", + "#n1v4", + "#n2applied", + "#n2t3", + "#n2t4", + "#n2v3", + "#n2v4", + "#n3applied", + "#n3t3", + "#n3t4", + "#n3v2hi", + "#n3v3", + "#n3v4", + ]); + + // Initial state: Start from ending state of fig3a (but with arrows hidden) + // Show elements that were visible at end of fig3a + animator + .morphText("#n1t2", "6", { duration: 0 }) + .show("#n1t2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n1v2", "rs2", { duration: 0 }) + .show("#n1v2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n2t2", "6", { duration: 0 }) + .show("#n2t2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n2v2", "rs2", { duration: 0 }) + .show("#n2v2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n3t2", "6", { duration: 0 }) + .show("#n3t2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n3v2", "rs2", { duration: 0 }) + .show("#n3v2", { autoAlpha: 1, fill: COLORS.orange }) + .show("#n1term", { fill: COLORS.orange }) + .morphText("#n1term", "6", { duration: 0 }) + .show("#n2term", { fill: COLORS.orange }) + .morphText("#n2term", "6", { duration: 0 }) + .show("#n3term", { fill: COLORS.orange }) + .morphText("#n3term", "6", { duration: 0 }) + .show("#ctext", { fill: COLORS.orange }) + .show("#c", { stroke: COLORS.orange }); + + // Step 1: C6 promotes N1 as leader + animator + .show("#cn1", { stroke: COLORS.orange }) + .animateArrow("#cn1", { duration: DURATION.normal }) + .morphText("#cn1text", "Promote", { duration: DURATION.instant }) + .show("#cn1text", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#desc1", "* C6 promotes N1 as leader", { + duration: DURATION.instant, + }) + .show("#desc1", { autoAlpha: 1, fill: COLORS.orange }) + .show("#n3applied", { autoAlpha: 1 }); + + animator.addLabel("step1"); + + // Step 2: N1 and N2 apply 5-a and 6-rs2 + animator + .unanimateArrow("#cn1", { duration: DURATION.instant }) + .show("#cn1text", { autoAlpha: 0 }) + .show("#desc1", { fill: COLORS.inactive }) + .show("#n1applied", { autoAlpha: 1, stroke: COLORS.orange }) + .show("#n2applied", { autoAlpha: 1, stroke: COLORS.orange }); + + animator.group(() => { + animator.moveTo( + "#n1applied", + null, + { x: 80, y: 0 }, + { duration: DURATION.normal }, + ); + animator.moveTo( + "#n2applied", + null, + { x: 80, y: 0 }, + { duration: DURATION.normal }, + ); + }); + + animator + .morphText("#n1rule", "rs2", { duration: DURATION.instant }) + .show("#n1rule", { fill: COLORS.orange }) + .morphText("#n2rule", "rs2", { duration: DURATION.instant }) + .show("#n2rule", { fill: COLORS.orange }); + + animator + .morphText("#desc2", "* N1 and N2 apply 5-a and 6-rs2", { + duration: DURATION.instant, + }) + .show("#desc2", { autoAlpha: 1, fill: COLORS.orange }); + + animator.addLabel("step2"); + + // Step 3: N1 accepts request 6-B, propagates to N2 and applies it + animator + .show("#desc2", { fill: COLORS.inactive }) + .show("#n1t3", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n1t3", "6", { duration: DURATION.instant }) + .show("#n1v3", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n1v3", "B", { duration: DURATION.instant }) + .show("#n2t3", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n2t3", "6", { duration: DURATION.instant }) + .show("#n2v3", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n2v3", "B", { duration: DURATION.instant }); + + animator.group(() => { + animator.moveTo( + "#n1applied", + null, + { x: 120, y: 0 }, + { duration: DURATION.normal }, + ); + animator.moveTo( + "#n2applied", + null, + { x: 120, y: 0 }, + { duration: DURATION.normal }, + ); + }); + + animator + .morphText("#desc3", "* N1 accepts request 6-B,", { + duration: DURATION.instant, + }) + .show("#desc3", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#desc4", " propagates to N2", { + duration: DURATION.instant, + }) + .show("#desc4", { autoAlpha: 1, fill: COLORS.orange }); + + animator.addLabel("step3"); + + // Step 4: C7 discovers N3 + animator + .show("#desc1", { autoAlpha: 0 }) + .morphText("#desc1", "", { duration: DURATION.instant }) + .show("#desc2", { autoAlpha: 0 }) + .morphText("#desc2", "", { duration: DURATION.instant }) + .show("#desc3", { autoAlpha: 0 }) + .morphText("#desc3", "", { duration: DURATION.instant }) + .show("#desc4", { autoAlpha: 0 }) + .morphText("#desc4", "", { duration: DURATION.instant }) + .morphText("#ctext", "", { duration: DURATION.instant }) + .show("#c", { stroke: COLORS.blue }) + .show("#ctext", { fill: COLORS.blue }) + .show("#cn3", { stroke: COLORS.blue }) + .morphText("#ctext", "C7", { duration: DURATION.normal }) + .animateArrow("#cn3", { duration: DURATION.normal }) + .morphText("#cn3text", "Recruit", { duration: DURATION.instant }) + .show("#cn3text", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n3term", "7", { duration: DURATION.instant }) + .show("#n3term", { fill: COLORS.blue }) + .morphText("#desc1", "* C7 recruits N3", { + duration: DURATION.instant, + }) + .show("#desc1", { autoAlpha: 1, fill: COLORS.blue }); + + animator.addLabel("step4"); + + // Step 5: C7 discovers rs2, must recruit N2 + animator + .show("#desc1", { fill: COLORS.inactive }) + .show("#n3v2hi", { autoAlpha: 1, stroke: COLORS.blue }) + .morphText("#desc2", "* C7 discovers rs2,", { + duration: DURATION.instant, + }) + .show("#desc2", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#desc3", " must recruit N2", { + duration: DURATION.instant, + }) + .show("#desc3", { autoAlpha: 1, fill: COLORS.blue }); + + animator.addLabel("step5"); + + // Step 6: C7 recruits N2, discovers progressed timeline + animator + .show("#n3v2hi", { autoAlpha: 0 }) + .show("#desc2", { fill: COLORS.inactive }) + .show("#desc3", { fill: COLORS.inactive }) + .show("#cn2", { stroke: COLORS.blue }) + .animateArrow("#cn2", { duration: DURATION.normal }) + .morphText("#cn2text", "Recruit", { duration: DURATION.instant }) + .show("#cn2text", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n2term", "7", { duration: DURATION.instant }) + .show("#n2term", { fill: COLORS.blue }) + .morphText("#desc4", "* C7 recruits N2,", { + duration: DURATION.instant, + }) + .show("#desc4", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#desc5", " discovers progressed timeline", { + duration: DURATION.instant, + }) + .show("#desc5", { autoAlpha: 1, fill: COLORS.blue }); + + animator.addLabel("step6"); + + // Step 7: C7 promotes N1 into term 7, propagates 7-ok to all nodes + animator + .show("#desc1", { autoAlpha: 0 }) + .morphText("#desc1", "", { duration: DURATION.instant }) + .show("#desc2", { autoAlpha: 0 }) + .morphText("#desc2", "", { duration: DURATION.instant }) + .show("#desc3", { autoAlpha: 0 }) + .morphText("#desc3", "", { duration: DURATION.instant }) + .show("#desc4", { autoAlpha: 0 }) + .morphText("#desc4", "", { duration: DURATION.instant }) + .show("#desc5", { autoAlpha: 0 }) + .morphText("#desc5", "", { duration: DURATION.instant }) + .show("#cn1", { stroke: COLORS.blue }) + .unanimateArrow("#cn1", { duration: DURATION.instant }) + .unanimateArrow("#cn2", { duration: DURATION.instant }) + .unanimateArrow("#cn3", { duration: DURATION.instant }) + .show("#cn3text", { autoAlpha: 0 }) + .morphText("#cn3text", "", { duration: DURATION.instant }); + + animator.group(() => { + animator.animateArrow("#cn1", { duration: DURATION.normal }); + animator.animateArrow("#cn2", { duration: DURATION.normal }); + }); + + animator + .morphText("#cn1text", "ok", { duration: DURATION.instant }) + .show("#cn1text", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#cn2text", "ok", { duration: DURATION.instant }) + .show("#cn2text", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n1term", "7", { duration: DURATION.instant }) + .show("#n1term", { fill: COLORS.blue }) + .morphText("#n1t4", "7", { duration: DURATION.fast }) + .show("#n1t4", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n1v4", "ok", { duration: DURATION.fast }) + .show("#n1v4", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n2t4", "7", { duration: DURATION.fast }) + .show("#n2t4", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n2v4", "ok", { duration: DURATION.fast }) + .show("#n2v4", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#desc1", "* C7 appends 7-ok to N1 and N2", { + duration: DURATION.instant, + }) + .show("#desc1", { autoAlpha: 1, fill: COLORS.blue }); + + animator.addLabel("step7"); + + animator + .unanimateArrow("#cn2", { duration: DURATION.instant }) + .show("#cn2text", { autoAlpha: 0 }) + .morphText("#cn2text", "", { duration: DURATION.instant }) + .unanimateArrow("#cn1", { duration: DURATION.instant }) + .show("#cn1text", { autoAlpha: 0 }) + .morphText("#cn1text", "", { duration: DURATION.instant }) + .animateArrow("#cn1", { duration: DURATION.normal }) + .morphText("#cn1text", "Promote", { duration: DURATION.instant }) + .show("#cn1text", { autoAlpha: 1 }) + .show("#desc1", { autoAlpha: 1, fill: COLORS.inactive }) + .morphText("#desc2", "* C7 promotes N1", { + duration: DURATION.instant, + }) + .show("#desc2", { autoAlpha: 1, fill: COLORS.blue }); + + animator.addLabel("step8"); + + animator + .unanimateArrow("#cn1", { duration: DURATION.instant }) + .show("#cn1text", { autoAlpha: 0 }) + .morphText("#cn1text", "", { duration: DURATION.instant }) + .show("#c", { stroke: COLORS.inactive }) + .show("#ctext", { fill: COLORS.inactive }) + .show("#n1applied", { stroke: COLORS.blue }) + .show("#n2applied", { stroke: COLORS.blue }); + + animator.group(() => { + animator.moveTo( + "#n1applied", + null, + { x: 160, y: 0 }, + { duration: DURATION.normal }, + ); + animator.moveTo( + "#n2applied", + null, + { x: 160, y: 0 }, + { duration: DURATION.normal }, + ); + }); + + animator + .show("#desc2", { autoAlpha: 1, fill: COLORS.inactive }) + .morphText("#desc3", "* N1 and N2 apply 7-ok", { + duration: DURATION.instant, + }) + .show("#desc3", { autoAlpha: 1, fill: COLORS.blue }); + + animator + .morphText("#n3t3", "6", { duration: DURATION.fast }) + .show("#n3t3", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n3v3", "B", { duration: DURATION.fast }) + .show("#n3v3", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n3t4", "7", { duration: DURATION.fast }) + .show("#n3t4", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n3v4", "ok", { duration: DURATION.fast }) + .show("#n3v4", { autoAlpha: 1, fill: COLORS.blue }) + .show("#n3applied", { stroke: COLORS.blue }) + .moveTo("#n3applied", null, { x: 160, y: 0 }, { duration: DURATION.normal }) + .morphText("#desc4", "* N1 propagates events to N3", { + duration: DURATION.instant, + }) + .show("#desc4", { autoAlpha: 1, fill: COLORS.blue }); + + animator.addLabel("step9"); +}; diff --git a/src/components/part08Fig3c.ts b/src/components/part08Fig3c.ts new file mode 100644 index 0000000..8158ba3 --- /dev/null +++ b/src/components/part08Fig3c.ts @@ -0,0 +1,232 @@ +/** + * Animation configuration for Part 8 Figure 3c - Ruleset change scenario 3 + * Starting from ending state of fig3b + */ + +import { SVGAnimator } from "../lib/svg-animator"; + +const COLORS = SVGAnimator.COLORS; +const DURATION = SVGAnimator.DURATION; + +/** + * Animation sequence for part08-fig3.svg - Scenario 3 + * Shows propagation using combined ruleset + */ +export const part08Fig3c = (animator: SVGAnimator) => { + // Hide elements that will be animated in this scenario + animator.hideElements([ + "#cn1", + "#cn1text", + "#cn2", + "#cn2text", + "#cn3", + "#cn3text", + "#desc1", + "#desc2", + "#desc3", + "#desc4", + "#desc5", + "#n1applied", + "#n1t3", + "#n1t4", + "#n1v3", + "#n1v4", + "#n2applied", + "#n2t3", + "#n2t4", + "#n2v3", + "#n2v4", + "#n3applied", + "#n3t3", + "#n3t4", + "#n3v2hi", + "#n3v3", + "#n3v4", + ]); + + // Initial state: Start from ending state of fig3b + animator + .morphText("#n1t2", "6", { duration: 0 }) + .show("#n1t2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n1v2", "rs2", { duration: 0 }) + .show("#n1v2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n2t2", "6", { duration: 0 }) + .show("#n2t2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n2v2", "rs2", { duration: 0 }) + .show("#n2v2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n3t2", "6", { duration: 0 }) + .show("#n3t2", { autoAlpha: 1, fill: COLORS.orange }) + .morphText("#n3v2", "rs2", { duration: 0 }) + .show("#n3v2", { autoAlpha: 1, fill: COLORS.orange }) + .show("#n1term", { fill: COLORS.orange }) + .morphText("#n1term", "6", { duration: 0 }) + .show("#n2term", { fill: COLORS.orange }) + .morphText("#n2term", "6", { duration: 0 }) + .show("#n3term", { fill: COLORS.orange }) + .morphText("#n3term", "7", { duration: 0 }) + .show("#ctext", { fill: COLORS.blue }) + .show("#c", { stroke: COLORS.blue }) + .morphText("#ctext", "C7", { duration: 0 }); + + // Step 1: C7 recruits N3 + animator + .show("#cn3", { stroke: COLORS.blue }) + .animateArrow("#cn3", { duration: DURATION.normal }) + .morphText("#cn3text", "Recruit", { duration: DURATION.instant }) + .show("#cn3text", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n3term", "7", { duration: DURATION.instant }) + .show("#n3term", { fill: COLORS.blue }) + .morphText("#desc1", "* C7 recruits N3", { + duration: DURATION.instant, + }) + .show("#desc1", { autoAlpha: 1, fill: COLORS.blue }) + .wait(DURATION.pause); + + animator.addLabel("step1"); + + // Step 2: C7 discovers rs2, must recruit N2 + animator + .show("#desc1", { fill: COLORS.inactive }) + .show("#n3v2hi", { autoAlpha: 1, stroke: COLORS.blue }) + .morphText("#desc2", "* C7 discovers rs2,", { + duration: DURATION.instant, + }) + .show("#desc2", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#desc3", " must recruit N2", { + duration: DURATION.instant, + }) + .show("#desc3", { autoAlpha: 1, fill: COLORS.blue }) + .wait(DURATION.pause); + + animator.addLabel("step2"); + + // Step 3: C7 recruits N2, discovers timeline has not progressed + animator + .show("#n3v2hi", { autoAlpha: 0 }) + .show("#desc2", { fill: COLORS.inactive }) + .show("#desc3", { fill: COLORS.inactive }) + .show("#cn2", { stroke: COLORS.blue }) + .animateArrow("#cn2", { duration: DURATION.normal }) + .morphText("#cn2text", "Recruit", { duration: DURATION.instant }) + .show("#cn2text", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n2term", "7", { duration: DURATION.instant }) + .show("#n2term", { fill: COLORS.blue }) + .morphText("#desc2", "* C7 recruits N2", { + duration: DURATION.instant, + }) + .show("#desc2", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#desc3", "* C7 discovers same timeline", { + duration: DURATION.instant, + }) + .show("#desc3", { autoAlpha: 1, fill: COLORS.blue }) + .wait(DURATION.pause); + + animator.addLabel("step3"); + + // Step 4: C7 propagates 7-ok to all three nodes, satisfying rs1 and rs2 + animator + .show("#desc2", { fill: COLORS.inactive }) + .show("#desc3", { fill: COLORS.inactive }) + .show("#cn1", { stroke: COLORS.blue }); + + animator.group(() => { + animator.animateArrow("#cn1", { duration: DURATION.normal }); + animator.animateArrow("#cn2", { duration: DURATION.normal }); + animator.animateArrow("#cn3", { duration: DURATION.normal }); + }); + + animator + .morphText("#cn1text", "ok", { duration: DURATION.instant }) + .show("#cn1text", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#cn2text", "ok", { duration: DURATION.instant }) + .show("#cn2text", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#cn3text", "ok", { duration: DURATION.instant }) + .show("#cn3text", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n1term", "7", { duration: DURATION.instant }) + .show("#n1term", { fill: COLORS.blue }) + .morphText("#n1t3", "7", { duration: DURATION.instant }) + .show("#n1t3", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n1v3", "ok", { duration: DURATION.instant }) + .show("#n1v3", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n2t3", "7", { duration: DURATION.instant }) + .show("#n2t3", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n2v3", "ok", { duration: DURATION.instant }) + .show("#n2v3", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n3t3", "7", { duration: DURATION.instant }) + .show("#n3t3", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#n3v3", "ok", { duration: DURATION.instant }) + .show("#n3v3", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#desc4", "* C7 propagates 7-ok to all three", { + duration: DURATION.instant, + }) + .show("#desc4", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#desc5", " nodes, satisfying rs1 and rs2", { + duration: DURATION.instant, + }) + .show("#desc5", { autoAlpha: 1, fill: COLORS.blue }) + .wait(DURATION.pause); + + animator.addLabel("step4"); + + // Step 5: C7 promotes N1, N1/N2/N3 apply pending requests + animator + .show("#desc1", { autoAlpha: 0 }) + .morphText("#desc1", "", { duration: DURATION.instant }) + .show("#desc2", { autoAlpha: 0 }) + .morphText("#desc2", "", { duration: DURATION.instant }) + .show("#desc3", { autoAlpha: 0 }) + .morphText("#desc3", "", { duration: DURATION.instant }) + .show("#desc4", { autoAlpha: 0 }) + .morphText("#desc4", "", { duration: DURATION.instant }) + .show("#desc5", { autoAlpha: 0 }) + .morphText("#desc5", "", { duration: DURATION.instant }) + .unanimateArrow("#cn1", { duration: DURATION.instant }) + .unanimateArrow("#cn2", { duration: DURATION.instant }) + .unanimateArrow("#cn3", { duration: DURATION.instant }) + .show("#cn1text", { autoAlpha: 0 }) + .show("#cn2text", { autoAlpha: 0 }) + .show("#cn3text", { autoAlpha: 0 }) + .show("#cn1", { stroke: COLORS.blue }) + .animateArrow("#cn1", { duration: DURATION.normal }) + .morphText("#cn1text", "Promote", { duration: DURATION.instant }) + .show("#cn1text", { autoAlpha: 1, fill: COLORS.blue }) + .morphText("#desc1", "* C7 promotes N1", { + duration: DURATION.instant, + }) + .show("#desc1", { autoAlpha: 1, fill: COLORS.blue }); + + animator + .show("#n1applied", { autoAlpha: 1, stroke: COLORS.blue }) + .show("#n2applied", { autoAlpha: 1, stroke: COLORS.blue }) + .show("#n3applied", { autoAlpha: 1, stroke: COLORS.blue }); + + animator.group(() => { + animator.moveTo( + "#n1applied", + null, + { x: 120, y: 0 }, + { duration: DURATION.normal }, + ); + animator.moveTo( + "#n2applied", + null, + { x: 120, y: 0 }, + { duration: DURATION.normal }, + ); + animator.moveTo( + "#n3applied", + null, + { x: 120, y: 0 }, + { duration: DURATION.normal }, + ); + }); + + animator + .morphText("#desc2", "* N1, N2, N3 apply pending requests", { + duration: DURATION.instant, + }) + .show("#desc2", { autoAlpha: 1, fill: COLORS.blue }) + .wait(DURATION.pause); + + animator.addLabel("step5"); +}; diff --git a/src/components/requestProcessing.ts b/src/components/requestProcessing.ts new file mode 100644 index 0000000..0aafd69 --- /dev/null +++ b/src/components/requestProcessing.ts @@ -0,0 +1,472 @@ +/** + * Animation configuration for the Multigres Architecture Overview diagram + * This defines the animation sequence for /img/site/req1.svg + */ + +import type { SVGAnimator } from "../lib/svg-animator"; +import { SVGAnimator as SVGAnimatorClass } from "../lib/svg-animator"; + +// Reuse constants from SVGAnimator +const COLORS = SVGAnimatorClass.COLORS; +const DURATION = SVGAnimatorClass.DURATION; + +// Helper function to hide multiple elements +const hideElements = (animator: SVGAnimator, elements: string[]) => { + elements.forEach((el) => animator.set(el, { autoAlpha: 0 })); + return animator; +}; + +// Helper to update description text +const updateDescriptions = ( + animator: SVGAnimator, + descriptions: { [key: string]: string }, + activeColor = COLORS.active, +) => { + Object.entries(descriptions).forEach(([id, text]) => { + if (text) { + animator + .show(id, { fill: activeColor }) + .morphText(id, text, { duration: DURATION.instant }); + } else { + animator.morphText(id, "", { duration: DURATION.instant }); + } + }); + return animator; +}; + +// Helper to clear all descriptions +const clearDescriptions = (animator: SVGAnimator) => { + return updateDescriptions(animator, { + "#desc1": "", + "#desc2": "", + "#desc3": "", + "#desc4": "", + "#desc5": "", + }); +}; + +// Helper to send message to multiple nodes +const sendToFollowers = ( + animator: SVGAnimator, + message: string, + nodes: string[] = ["#n1n2", "#n1n3", "#n1n4"], +) => { + animator.group((a: SVGAnimator) => { + nodes.forEach((node) => { + a.morphText(`${node}text`, message, { duration: DURATION.instant }) + .show(`${node}text`, { autoAlpha: 1 }) + .animateArrow(node, { duration: DURATION.normal }); + }); + }); + return animator; +}; + +// Helper to acknowledge from nodes +const acknowledgeFrom = ( + animator: SVGAnimator, + message: string, + nodes: string[] = ["#n2n1", "#n4n1"], +) => { + animator.group((a: SVGAnimator) => { + nodes.forEach((node) => { + a.morphText(`${node}text`, message, { duration: DURATION.instant }) + .show(node, { stroke: COLORS.active }) + .show(`${node}text`, { autoAlpha: 1, fill: COLORS.active }) + .animateArrow(node, { duration: DURATION.normal }); + }); + }); + return animator; +}; + +// Helper to reset colors for elements +const resetColors = ( + animator: SVGAnimator, + elements: string[], + type: "fill" | "stroke" = "fill", +) => { + elements.forEach((el) => { + animator.show(el, { [type]: COLORS.inactive }); + }); + return animator; +}; + +// Helper to hide and unanimate arrows +const hideArrows = (animator: SVGAnimator, arrows: string[]) => { + arrows.forEach((arrow) => { + animator + .show(`${arrow}text`, { autoAlpha: 0 }) + .unanimateArrow(arrow, { duration: DURATION.instant }); + }); + return animator; +}; + +export const requestProcessing = (animator: SVGAnimator) => { + // Initial setup - hide all dynamic elements + const elementsToHide = [ + "#appa", + "#appatext", + "#n1a", + "#aapp", + "#aapptext", + "#appb", + "#appbtext", + "#bapp", + "#bapptext", + "#n1b", + "#n1n2", + "#n1n2text", + "#n1n3", + "#n1n3text", + "#n1n4", + "#n1n4text", + "#n2n1", + "#n2n1text", + "#n3n1", + "#n3n1text", + "#n4n1", + "#n4n1text", + "#n2a", + "#n2b", + "#n3a", + "#n3b", + "#n4a", + "#n4b", + "#n1o", + "#n1otext", + ]; + hideElements(animator, elementsToHide); + + animator + // === Step 1: Request A === + // Step 1a: App issues request A + .animateArrow("#appa", { duration: DURATION.normal }) + .show("#appatext", { autoAlpha: 1 }); + + updateDescriptions(animator, { "#desc1": "* App issues request A" }); + animator.addLabel("step1a"); + + // Step 1b: N1 saves A to log + animator.show("#n1a", { autoAlpha: 1, fill: COLORS.active }); + updateDescriptions(animator, { + "#desc2": "* N1 saves A to log", + }); + animator.show("#desc1", { fill: COLORS.inactive }).addLabel("step1b"); + + // Step 1c: N1 sends A to all followers + sendToFollowers(animator, "A"); + updateDescriptions(animator, { + "#desc3": "* N1 sends A to all followers", + }); + animator.show("#desc2", { fill: COLORS.inactive }).addLabel("step1c"); + + // Step 1: Followers save A to their logs + animator + .show("#n2a", { autoAlpha: 1, fill: COLORS.active }) + .show("#n3a", { autoAlpha: 1, fill: COLORS.active }) + .show("#n4a", { autoAlpha: 1, fill: COLORS.active }); + + updateDescriptions(animator, { + "#desc4": "* Followers save A to their logs", + }); + animator.show("#desc3", { fill: COLORS.inactive }).addLabel("step1"); + + // Reset for step 2 + hideArrows(animator, ["#n1n2", "#n1n3", "#n1n4"]); + resetColors(animator, ["#n1a", "#n2a", "#n3a", "#n4a"], "fill"); + animator + .show("#appa", { stroke: COLORS.inactive }) + .show("#appatext", { fill: COLORS.inactive }); + clearDescriptions(animator); + animator.addLabel("step2a"); + + // === Step 2: Acknowledge A === + // Step 2b: N2 and N4 ack A + acknowledgeFrom(animator, "Ack A"); + updateDescriptions(animator, { + "#desc1": "* N2 and N4 ack A", + "#desc2": "* N3 ack is delayed", + }); + animator.addLabel("step2b"); + + // Step 2: N1 records A as acked for N2 + animator + .show("#n2ack", { fill: COLORS.active }) + .morphText("#n2ack", "N2: A", { duration: DURATION.instant }); + + updateDescriptions(animator, { + "#desc3": "* N1 records A as acked for N2", + "#desc4": "* Ack for N4 ignored", + "#desc5": "* Durability requirements not met", + }); + animator + .show("#desc1", { fill: COLORS.inactive }) + .show("#desc2", { fill: COLORS.inactive }) + .addLabel("step2"); + + // Reset for step 3 + animator + .show("#n2n1text", { fill: COLORS.inactive }) + .show("#n4n1text", { fill: COLORS.inactive }) + .show("#n2ack", { fill: COLORS.inactive }); + clearDescriptions(animator); + hideArrows(animator, ["#n2n1", "#n4n1"]); + animator.addLabel("step3a"); + + // === Step 3: Request B === + // Step 3b: App issues request B + animator + .show("#n2ack", { fill: COLORS.inactive }) + .animateArrow("#appb", { duration: DURATION.normal }) + .show("#appbtext", { autoAlpha: 1 }); + + updateDescriptions(animator, { "#desc1": "* App issues request B" }); + animator.addLabel("step3b"); + + // Step 3c: N1 appends B to log + animator.show("#n1b", { autoAlpha: 1 }); + updateDescriptions(animator, { + "#desc2": "* N1 appends B to log", + }); + animator.show("#desc1", { fill: COLORS.inactive }).addLabel("step3c"); + + // Step 3d: N1 sends B to all followers + sendToFollowers(animator, "B"); + updateDescriptions(animator, { + "#desc3": "* N1 sends B to all followers", + }); + animator.show("#desc2", { fill: COLORS.inactive }).addLabel("step3d"); + + // Step 3: Followers append B to their logs + animator + .show("#n2b", { autoAlpha: 1 }) + .show("#n3b", { autoAlpha: 1 }) + .show("#n4b", { autoAlpha: 1 }); + + updateDescriptions(animator, { + "#desc4": "* Followers append B to their logs", + "#desc5": "* A is still not applied", + }); + animator.show("#desc3", { fill: COLORS.inactive }).addLabel("step3"); + + // === Step 4: Acknowledge B and delayed A === + // Step 4a: N2 and N4 ack B + hideArrows(animator, ["#n1n2", "#n1n3", "#n1n4"]); + acknowledgeFrom(animator, "Ack B"); + clearDescriptions(animator); + updateDescriptions(animator, { "#desc1": "* N2 and N4 ack B" }); + animator.addLabel("step4a"); + + // Step 4b: N1 records B as acked for N2 + animator + .show("#n2ack", { fill: COLORS.active }) + .morphText("#n2ack", "N2: B", { duration: DURATION.instant }); + + updateDescriptions(animator, { + "#desc2": "* N1 records B as acked for N2", + }); + animator.show("#desc1", { fill: COLORS.inactive }).addLabel("step4b"); + + // Step 4c: N3 acks A (delayed) + animator + .show("#n3n1", { stroke: COLORS.active }) + .morphText("#n3n1text", "Ack A", { duration: DURATION.instant }) + .animateArrow("#n3n1", { duration: DURATION.normal }) + .show("#n3n1text", { autoAlpha: 1, fill: COLORS.active }); + + updateDescriptions(animator, { + "#desc3": "* N3 acks A (delayed)", + }); + animator.show("#desc2", { fill: COLORS.inactive }).addLabel("step4c"); + + // Step 4: A meets durability criteria + animator + .show("#n3ack", { fill: COLORS.active }) + .morphText("#n3ack", "N3: A", { duration: DURATION.instant }); + + updateDescriptions(animator, { + "#desc4": "* N1 records A as acked for N3.", + "#desc5": " A meets durability criteria", + }); + animator.show("#desc3", { fill: COLORS.inactive }).addLabel("step4"); + + // Reset for step 5 + animator + .unanimateArrow("#n2n1", { duration: DURATION.instant }) + .unanimateArrow("#n3n1", { duration: DURATION.instant }) + .unanimateArrow("#n4n1", { duration: DURATION.instant }) + .show("#n2n1text", { autoAlpha: 0 }) + .show("#n3n1text", { autoAlpha: 0 }) + .show("#n4n1text", { autoAlpha: 0 }); + + resetColors(animator, ["#n1b", "#n2b", "#n3b", "#n4b"], "fill"); + clearDescriptions(animator); + animator + .show("#appb", { stroke: COLORS.inactive }) + .show("#appbtext", { fill: COLORS.inactive }) + .show("#n2ack", { fill: COLORS.inactive }) + .show("#n3ack", { fill: COLORS.inactive }) + .addLabel("step5a"); + + // === Step 5: Apply A === + // Step 5b: N1 applies A + animator.group((a: SVGAnimator) => { + a.show("#n1applied", { stroke: COLORS.active }) + .show("#n1appliedtext", { fill: COLORS.active }) + .moveTo( + "#n1applied", + null, + { x: 40, y: 0 }, + { duration: DURATION.normal }, + ) + .moveTo( + "#n1appliedtext", + null, + { x: 40, y: 0 }, + { duration: DURATION.normal }, + ); + }); + + updateDescriptions(animator, { + "#desc1": "* N1 applies A", + }); + animator.addLabel("step5b"); + + // Step 5c: N1 Acks A to App + animator.group((a: SVGAnimator) => { + a.show("#appa", { autoAlpha: 0 }) + .show("#appatext", { autoAlpha: 0 }) + .animateArrow("#aapp", { duration: DURATION.normal }) + .show("#aapptext", { autoAlpha: 1 }); + }); + animator.show("#desc1", { fill: COLORS.inactive }); + updateDescriptions(animator, { + "#desc2": "* N1 Acks A to App", + }); + animator.addLabel("step5c"); + + // Step 5d: N1 sends Apply A to followers and observers + sendToFollowers(animator, "Apply A", ["#n1n2", "#n1n3", "#n1n4", "#n1o"]); + animator.show("#desc2", { fill: COLORS.inactive }); + updateDescriptions(animator, { + "#desc3": "* N1 sends Apply A to followers", + "#desc4": " and observers", + }); + animator.addLabel("step5d"); + + // Step 5: Followers and observers apply A + animator.group((a: SVGAnimator) => { + a.show("#n4applied", { stroke: COLORS.active }) + .show("#n4appliedtext", { fill: COLORS.active }) + .moveTo( + "#n4applied", + null, + { x: 40, y: 0 }, + { duration: DURATION.normal }, + ) + .moveTo( + "#n4appliedtext", + null, + { x: 40, y: 0 }, + { duration: DURATION.normal }, + ); + }); + animator + .show("#desc3", { fill: COLORS.inactive }) + .show("#desc4", { fill: COLORS.inactive }); + updateDescriptions(animator, { + "#desc5": "* Followers and observers apply A", + }); + animator.addLabel("step5"); + + // Reset for step 6 + hideArrows(animator, ["#n1n2", "#n1n3", "#n1n4", "#n1o"]); + animator + .unanimateArrow("#aapp", { duration: DURATION.instant }) + .show("#aapptext", { autoAlpha: 0 }) + .show("#n1applied", { stroke: COLORS.inactive }) + .show("#n1appliedtext", { fill: COLORS.inactive }) + .show("#n4applied", { stroke: COLORS.inactive }) + .show("#n4appliedtext", { fill: COLORS.inactive }) + .addLabel("step6a"); + + // === Step 6 & 7: Apply B === + // Step 6b: N3 acks B (delayed) + clearDescriptions(animator); + animator + .show("#n3n1", { stroke: COLORS.active }) + .morphText("#n3n1text", "Ack B", { duration: DURATION.instant }) + .animateArrow("#n3n1", { duration: DURATION.normal }) + .show("#n3n1text", { autoAlpha: 1, fill: COLORS.active }); + + updateDescriptions(animator, { + "#desc1": "* N3 acks B (delayed)", + }); + animator.addLabel("step6b"); + + // Step 6c: N1 records B as acked for N3 + animator + .show("#n3ack", { fill: COLORS.active }) + .morphText("#n3ack", "N3: B", { duration: DURATION.instant }); + animator.show("#desc1", { fill: COLORS.inactive }); + updateDescriptions(animator, { + "#desc2": "* N1 recods B as acked for N3", + }); + animator.addLabel("step6c"); + + // Step 7: N1 applies B and sends to all + animator + .show("#n3n1text", { autoAlpha: 0 }) + .unanimateArrow("#n3n1", { duration: DURATION.instant }) + .show("#n3ack", { fill: COLORS.inactive }); + + animator.group((a: SVGAnimator) => { + a.show("#n1applied", { stroke: COLORS.active }) + .show("#n1appliedtext", { fill: COLORS.active }) + .moveTo( + "#n1applied", + null, + { x: 80, y: 0 }, + { duration: DURATION.normal }, + ) + .moveTo( + "#n1appliedtext", + null, + { x: 80, y: 0 }, + { duration: DURATION.normal }, + ); + }); + + animator + .unanimateArrow("#appb", { duration: DURATION.instant }) + .show("#appbtext", { autoAlpha: 0 }) + .animateArrow("#bapp", { duration: DURATION.normal }) + .show("#bapptext", { autoAlpha: 1, fill: COLORS.active }); + + sendToFollowers(animator, "Apply B", ["#n1n2", "#n1n3", "#n1n4", "#n1o"]); + + animator.group((a: SVGAnimator) => { + a.show("#n4applied", { stroke: COLORS.active }) + .show("#n4appliedtext", { fill: COLORS.active }) + .moveTo( + "#n4applied", + null, + { x: 80, y: 0 }, + { duration: DURATION.normal }, + ) + .moveTo( + "#n4appliedtext", + null, + { x: 80, y: 0 }, + { duration: DURATION.normal }, + ); + }); + + animator.show("#desc2", { fill: COLORS.inactive }); + updateDescriptions(animator, { + "#desc3": "* N1 performs all apply steps", + "#desc4": " for B as it did for A", + "#desc5": "", + }); + + animator.addLabel("step7"); +}; diff --git a/src/lib/svg-animator.ts b/src/lib/svg-animator.ts new file mode 100644 index 0000000..abe46bf --- /dev/null +++ b/src/lib/svg-animator.ts @@ -0,0 +1,841 @@ +/** + * GSAP Animation Wrapper for SVG Components + * + * This utility provides a simple interface to animate SVG elements using GSAP. + * It can be used to create complex animations for architectural diagrams and illustrations. + * + * @example + * ```typescript + * import { SVGAnimator } from '@site/src/lib/svg-animator'; + * + * const animator = new SVGAnimator('#my-svg'); + * animator.fadeIn('.node-1', { duration: 1, delay: 0.5 }); + * animator.drawArrow('.arrow-1', { duration: 2 }); + * ``` + */ + +import gsap from "gsap"; + +export interface AnimationOptions { + duration?: number; + delay?: number; + ease?: string; + stagger?: number; + onComplete?: () => void; + onStart?: () => void; +} + +export class SVGAnimator { + /** + * Color palette for animations + * - active: Green - active/success state + * - inactive: Dark gray - inactive state + * - blue: Blue - coordinator/term color + * - orange: Orange - coordinator/term color + * - purple: Purple - coordinator/term color + * - red: Red - error/crash state + */ + static readonly COLORS = { + active: "#2f9e44", + inactive: "#ddd", + blue: "#70bafb", + orange: "#af5900", + purple: "#e99cfe", + red: "#f97f81", + } as const; + + /** + * Animation duration presets in seconds + * - instant: No animation + * - fast: Quick transitions + * - normal: Standard animation speed + * - pause: Short pause between steps + * - slow: Slow, emphasized animations + */ + static readonly DURATION = { + instant: 0, + fast: 0.5, + normal: 1, + pause: 0.5, + slow: 2, + } as const; + + private svg: SVGElement | null; + private timeline: gsap.core.Timeline; + private steps: string[] = []; + private currentStep: number = -1; + private groupStartTime: number | null = null; + + constructor(svgSelector: string | SVGElement) { + if (typeof svgSelector === "string") { + this.svg = document.querySelector(svgSelector); + } else { + this.svg = svgSelector; + } + + if (!this.svg) { + console.warn(`SVG element not found: ${svgSelector}`); + } + + this.timeline = gsap.timeline({ paused: true }); + } + + /** + * Get the GSAP timeline for custom animations + */ + getTimeline(): gsap.core.Timeline { + return this.timeline; + } + + /** + * Select elements within the SVG + */ + private select(selector: string): Element[] { + if (!this.svg) return []; + return Array.from(this.svg.querySelectorAll(selector)); + } + + /** + * Set properties on elements immediately (before timeline starts) + */ + set(selector: string, properties: gsap.TweenVars): this { + const elements = this.select(selector); + if (elements.length === 0) { + console.warn(`No elements found for selector: ${selector}`); + return this; + } + + // Use immediate gsap.set for initial setup (before timeline plays) + gsap.set(elements, properties); + return this; + } + + /** + * Hide multiple SVG elements by setting autoAlpha to 0 + * Common pattern for setting up initial state of animations + * + * @param selectors - Array of CSS selectors for elements to hide + * @returns The animator instance for chaining + */ + hideElements(selectors: string[]): this { + selectors.forEach((selector) => this.set(selector, { autoAlpha: 0 })); + return this; + } + + /** + * Set text content immediately without adding to timeline + */ + setText(selector: string, text: string): this { + const elements = this.select(selector); + if (elements.length === 0) { + console.warn(`No elements found for selector: ${selector}`); + return this; + } + + elements.forEach((element) => { + if (element instanceof SVGTextElement) { + element.textContent = text; + } + }); + return this; + } + + /** + * Change text with animation: clear, set color, then morph to new text + * Common pattern: clear text -> set color -> morph to new text + */ + changeText( + selector: string, + text: string, + fill: string, + duration: number = 0.5, + ): this { + this.morphText(selector, "", { duration: 0 }) + .show(selector, { fill }) + .morphText(selector, text, { duration }); + return this; + } + + /** + * Set properties on elements as part of the timeline animation + * Uses a very short duration so it works with reverse navigation + */ + show(selector: string, properties: gsap.TweenVars = {}): this { + const elements = this.select(selector); + if (elements.length === 0) { + console.warn(`No elements found for selector: ${selector}`); + return this; + } + + // Collect all child elements as well + const allElements: Element[] = []; + elements.forEach((element) => { + allElements.push(element); + allElements.push(...Array.from(element.querySelectorAll("*"))); + }); + + // Use a very short duration animation instead of set() so it reverses properly + this.timeline.to( + allElements, + { + ...properties, + duration: 0.01, + }, + this.groupStartTime ?? undefined, + ); + return this; + } + + /** + * Fade in elements + */ + fadeIn(selector: string, options: AnimationOptions = {}): this { + const elements = this.select(selector); + if (elements.length === 0) { + console.warn(`No elements found for selector: ${selector}`); + return this; + } + + gsap.set(elements, { opacity: 0 }); + this.timeline.to( + elements, + { + opacity: 1, + duration: options.duration ?? 0.5, + delay: options.delay ?? 0, + ease: options.ease ?? "power2.out", + stagger: options.stagger ?? 0, + onComplete: options.onComplete, + onStart: options.onStart, + }, + this.groupStartTime ?? undefined, + ); + + return this; + } + + /** + * Fade out elements + */ + fadeOut(selector: string, options: AnimationOptions = {}): this { + const elements = this.select(selector); + if (elements.length === 0) return this; + + this.timeline.to( + elements, + { + opacity: 0, + duration: options.duration ?? 0.5, + delay: options.delay ?? 0, + ease: options.ease ?? "power2.out", + stagger: options.stagger ?? 0, + onComplete: options.onComplete, + onStart: options.onStart, + }, + this.groupStartTime ?? undefined, + ); + + return this; + } + + /** + * Scale animation + */ + scale(selector: string, scale: number, options: AnimationOptions = {}): this { + const elements = this.select(selector); + if (elements.length === 0) return this; + + this.timeline.to( + elements, + { + scale, + duration: options.duration ?? 0.5, + delay: options.delay ?? 0, + ease: options.ease ?? "back.out(1.7)", + stagger: options.stagger ?? 0, + transformOrigin: "center center", + onComplete: options.onComplete, + onStart: options.onStart, + }, + this.groupStartTime ?? undefined, + ); + + return this; + } + + /** + * Draw arrow/path animation (stroke-dasharray technique) + */ + drawPath(selector: string, options: AnimationOptions = {}): this { + const elements = this.select(selector); + if (elements.length === 0) return this; + + elements.forEach((element) => { + if (element instanceof SVGGeometryElement) { + const length = element.getTotalLength(); + gsap.set(element, { + strokeDasharray: length, + strokeDashoffset: length, + }); + } + }); + + this.timeline.to( + elements, + { + strokeDashoffset: 0, + duration: options.duration ?? 1, + delay: options.delay ?? 0, + ease: options.ease ?? "power2.inOut", + stagger: options.stagger ?? 0, + onComplete: options.onComplete, + onStart: options.onStart, + }, + this.groupStartTime ?? undefined, + ); + + return this; + } + + /** + * Slide in from direction + */ + slideIn( + selector: string, + direction: "left" | "right" | "top" | "bottom" = "left", + options: AnimationOptions = {}, + ): this { + const elements = this.select(selector); + if (elements.length === 0) return this; + + const distance = 100; + const initialPosition = { + left: { x: -distance, y: 0 }, + right: { x: distance, y: 0 }, + top: { x: 0, y: -distance }, + bottom: { x: 0, y: distance }, + }[direction]; + + gsap.set(elements, { + x: initialPosition.x, + y: initialPosition.y, + opacity: 0, + }); + + this.timeline.to( + elements, + { + x: 0, + y: 0, + opacity: 1, + duration: options.duration ?? 0.8, + delay: options.delay ?? 0, + ease: options.ease ?? "power3.out", + stagger: options.stagger ?? 0, + onComplete: options.onComplete, + onStart: options.onStart, + }, + this.groupStartTime ?? undefined, + ); + + return this; + } + + /** + * Move element from one point to another + * @param selector - CSS selector for elements to move + * @param from - Starting position {x, y} or null to move from current position + * @param to - Ending position {x, y} + * @param options - Animation options + */ + moveTo( + selector: string, + from: { x: number; y: number } | null, + to: { x: number; y: number }, + options: AnimationOptions = {}, + ): this { + const elements = this.select(selector); + if (elements.length === 0) { + console.warn(`No elements found for selector: ${selector}`); + return this; + } + + // Set initial position if provided + if (from !== null) { + this.timeline.set( + elements, + { + x: from.x, + y: from.y, + }, + this.groupStartTime ?? undefined, + ); + } + + // Animate to target position + this.timeline.to( + elements, + { + x: to.x, + y: to.y, + duration: options.duration ?? 1, + delay: options.delay ?? 0, + ease: options.ease ?? "power2.inOut", + stagger: options.stagger ?? 0, + onComplete: options.onComplete, + onStart: options.onStart, + }, + this.groupStartTime ?? undefined, + ); + + return this; + } + + /** + * Highlight element with pulse animation + */ + pulse(selector: string, options: AnimationOptions = {}): this { + const elements = this.select(selector); + if (elements.length === 0) return this; + + this.timeline.to( + elements, + { + scale: 1.1, + duration: (options.duration ?? 0.6) / 2, + delay: options.delay ?? 0, + ease: "power2.inOut", + yoyo: true, + repeat: 1, + stagger: options.stagger ?? 0, + transformOrigin: "center center", + onComplete: options.onComplete, + onStart: options.onStart, + }, + this.groupStartTime ?? undefined, + ); + + return this; + } + + /** + * Morph text content from one value to another with animation + */ + morphText( + selector: string, + newText: string, + options: AnimationOptions = {}, + ): this { + const elements = this.select(selector); + if (elements.length === 0) return this; + + elements.forEach((element) => { + if (element instanceof SVGTextElement) { + // Capture the current text at the time this morphText is called + // This is what we'll restore when THIS specific tween reverses + const previousText = element.textContent || ""; + const tempObj = { value: 0 }; + + this.timeline.to( + tempObj, + { + value: 1, + duration: options.duration ?? 0.8, + delay: options.delay ?? 0, + ease: options.ease ?? "power2.inOut", + onUpdate: () => { + // Fade out and scale down + if (tempObj.value < 0.5) { + const progress = tempObj.value * 2; // 0 to 1 in first half + element.style.opacity = String(1 - progress); + element.style.transform = `scale(${1 - progress * 0.3})`; + } else { + // Change text at midpoint + if (element.textContent !== newText) { + element.textContent = newText; + } + // Fade in and scale up + const progress = (tempObj.value - 0.5) * 2; // 0 to 1 in second half + element.style.opacity = String(progress); + element.style.transform = `scale(${0.7 + progress * 0.3})`; + } + }, + onReverseComplete: () => { + // Restore the text from before this specific morphText call + element.textContent = previousText; + element.style.opacity = "1"; + element.style.transform = "scale(1)"; + }, + onComplete: () => { + element.style.opacity = "1"; + element.style.transform = "scale(1)"; + if (options.onComplete) options.onComplete(); + }, + onStart: options.onStart, + }, + this.groupStartTime ?? undefined, + ); + } + }); + + return this; + } + + /** + * Animate an arrow group where first element is the shaft and rest are arrowhead/decorations + * The shaft is drawn first, then other elements fade in + */ + animateArrow(groupSelector: string, options: AnimationOptions = {}): this { + const groups = this.select(groupSelector); + if (groups.length === 0) { + console.warn(`No arrow group found for selector: ${groupSelector}`); + return this; + } + + groups.forEach((group) => { + const children = Array.from(group.children); + if (children.length === 0) return; + + // We assume elements are hidden initially. + // Make them visible. + this.timeline.to( + group, + { + autoAlpha: 1, + duration: 0.01, + }, + this.groupStartTime ?? undefined, + ); + + // First child contains the shaft - find the actual path element + const firstChild = children[0]; + let shaftPath: SVGGeometryElement | null = null; + + // Check if first child is the path itself + if (firstChild instanceof SVGGeometryElement) { + shaftPath = firstChild; + } else { + // Look for a path element inside the first child + const pathElement = firstChild.querySelector( + "path, line, polyline, polygon", + ); + if (pathElement instanceof SVGGeometryElement) { + shaftPath = pathElement; + } + } + + // Animate the shaft using drawPath + if (shaftPath) { + let shaftSelector = ""; + if (shaftPath.id) { + shaftSelector = `#${shaftPath.id}`; + } else { + shaftSelector = `${groupSelector} > :first-child path, ${groupSelector} > :first-child line, ${groupSelector} > :first-child polyline, ${groupSelector} > :first-child polygon`; + } + + this.drawPath(shaftSelector, { + duration: options.duration ?? 1, + delay: options.delay ?? 0, + ease: options.ease ?? "none", + onStart: options.onStart, + }); + } + + // Remaining children are arrowhead/decorations - collect all descendants + const restElements: Element[] = []; + for (let i = 1; i < children.length; i++) { + restElements.push(children[i]); + // Also collect nested elements + restElements.push(...Array.from(children[i].querySelectorAll("*"))); + } + + if (restElements.length > 0) { + gsap.set(restElements, { opacity: 0 }); + // Don't use groupStartTime - arrowhead should appear AFTER shaft + this.timeline.to(restElements, { + opacity: 1, + duration: 0, + onComplete: options.onComplete, + }); + } else if (options.onComplete) { + this.timeline.call(options.onComplete); + } + }); + + return this; + } + + /** + * Reverse of animateArrow - fades out arrowhead first, then un-draws the shaft + */ + unanimateArrow(groupSelector: string, options: AnimationOptions = {}): this { + const groups = this.select(groupSelector); + if (groups.length === 0) { + console.warn(`No arrow group found for selector: ${groupSelector}`); + return this; + } + + groups.forEach((group) => { + const children = Array.from(group.children); + if (children.length === 0) return; + + // First child contains the shaft - find the actual path element + const firstChild = children[0]; + let shaftPath: SVGGeometryElement | null = null; + + // Check if first child is the path itself + if (firstChild instanceof SVGGeometryElement) { + shaftPath = firstChild; + } else { + // Look for a path element inside the first child + const pathElement = firstChild.querySelector( + "path, line, polyline, polygon", + ); + if (pathElement instanceof SVGGeometryElement) { + shaftPath = pathElement; + } + } + + // Remaining children are arrowhead/decorations - collect all descendants + const restElements: Element[] = []; + for (let i = 1; i < children.length; i++) { + restElements.push(children[i]); + // Also collect nested elements + restElements.push(...Array.from(children[i].querySelectorAll("*"))); + } + + // First, fade out the arrowhead + if (restElements.length > 0) { + this.timeline.to( + restElements, + { + opacity: 0, + duration: 0.001, + onStart: options.onStart, + }, + this.groupStartTime ?? undefined, + ); + } + + // Then un-draw the shaft + if (shaftPath) { + let shaftSelector = ""; + if (shaftPath.id) { + shaftSelector = `#${shaftPath.id}`; + } else { + shaftSelector = `${groupSelector} > :first-child path, ${groupSelector} > :first-child line, ${groupSelector} > :first-child polyline, ${groupSelector} > :first-child polygon`; + } + + const elements = this.select(shaftSelector); + elements.forEach((element) => { + if (element instanceof SVGGeometryElement) { + const length = element.getTotalLength(); + // Animate from drawn (offset 0) back to hidden (offset = length) + this.timeline.to( + element, + { + strokeDashoffset: length, + duration: options.duration ?? 1, + delay: options.delay ?? 0, + ease: options.ease ?? "none", + }, + this.groupStartTime ?? undefined, + ); + } + }); + } + + // Finally, hide the entire group + this.timeline.to(group, { + autoAlpha: 0, + duration: 0.01, + onComplete: options.onComplete, + }); + }); + + return this; + } + + /** + * Group animations to run simultaneously + * Creates a nested timeline where all animations in the callback run at the same time + */ + group(callback: (animator: SVGAnimator) => void): this { + // Mark the start position for the group + this.groupStartTime = this.timeline.duration(); + + // Execute the callback - all animations will be added at groupStartTime + callback(this); + + // Reset groupStartTime so subsequent animations continue sequentially + this.groupStartTime = null; + + return this; + } + + /** + * Add a delay to the timeline + */ + wait(duration: number): this { + this.timeline.add(() => {}, `+=${duration}`); + return this; + } + + /** + * Add a label to the timeline for seeking + */ + addLabel(label: string): this { + this.timeline.addLabel(label); + this.steps.push(label); + return this; + } + + /** + * Move to the next step in the animation + */ + nextStep(): this { + if (this.currentStep < this.steps.length - 1) { + this.currentStep++; + this.timeline.tweenTo(this.steps[this.currentStep]); + } + return this; + } + + /** + * Move to the previous step in the animation + */ + previousStep(): this { + if (this.currentStep > 0) { + this.currentStep--; + this.timeline.tweenTo(this.steps[this.currentStep]); + } else if (this.currentStep === 0) { + this.currentStep = -1; + this.timeline.tweenTo(0); + } + return this; + } + + /** + * Get the current step index + */ + getCurrentStep(): number { + return this.currentStep; + } + + /** + * Get the total number of steps + */ + getTotalSteps(): number { + return this.steps.length; + } + + /** + * Check if there's a next step + */ + hasNextStep(): boolean { + return this.currentStep < this.steps.length - 1; + } + + /** + * Check if there's a previous step + */ + hasPreviousStep(): boolean { + return this.currentStep >= 0; + } + + /** + * Play the animation + */ + play(): this { + this.timeline.play(); + return this; + } + + /** + * Pause the animation + */ + pause(): this { + this.timeline.pause(); + return this; + } + + /** + * Restart the animation + */ + restart(): this { + this.timeline.restart(); + return this; + } + + /** + * Seek to a specific time or label + */ + seek(timeOrLabel: number | string): this { + this.timeline.seek(timeOrLabel); + return this; + } + + /** + * Reverse the animation + */ + reverse(): this { + this.timeline.reverse(); + return this; + } + + /** + * Kill the animation + */ + kill(): void { + this.timeline.kill(); + } +} + +/** + * Create a new SVG animator instance + */ +export function createSVGAnimator( + svgSelector: string | SVGElement, +): SVGAnimator { + return new SVGAnimator(svgSelector); +} + +/** + * Utility function for common animation sequences + */ +export const animations = { + /** + * Fade in nodes sequentially, then draw arrows between them + */ + sequentialReveal: ( + animator: SVGAnimator, + nodeSelector: string, + arrowSelector: string, + options: { nodeDelay?: number; arrowDelay?: number } = {}, + ) => { + animator + .fadeIn(nodeSelector, { + duration: 0.5, + stagger: 0.2, + delay: options.nodeDelay ?? 0, + }) + .drawPath(arrowSelector, { + duration: 1, + stagger: 0.3, + delay: options.arrowDelay ?? 0, + }); + return animator; + }, + + /** + * Highlight a specific flow path + */ + highlightFlow: ( + animator: SVGAnimator, + flowSelector: string, + options: AnimationOptions = {}, + ) => { + animator.pulse(flowSelector, { duration: 0.8, ...options }); + return animator; + }, +};