From 2685dee3b1c81ceccf60ccfab6303892442d69e1 Mon Sep 17 00:00:00 2001 From: Varshita Singh Date: Wed, 22 Jul 2026 18:40:54 +0530 Subject: [PATCH] Add reusable dashboard animation components --- .../src/components/Animation/AnimatedCard.tsx | 12 + .../components/Animation/AnimationWrapper.tsx | 57 +++ frontend/src/components/Animation/FadeIn.tsx | 13 + frontend/src/components/Animation/SlideIn.tsx | 13 + frontend/src/hooks/useInViewAnimation.ts | 28 ++ frontend/src/pages/Dashboard.tsx | 403 +++++++++--------- frontend/src/utils/animationConfig.ts | 58 +++ 7 files changed, 389 insertions(+), 195 deletions(-) create mode 100644 frontend/src/components/Animation/AnimatedCard.tsx create mode 100644 frontend/src/components/Animation/AnimationWrapper.tsx create mode 100644 frontend/src/components/Animation/FadeIn.tsx create mode 100644 frontend/src/components/Animation/SlideIn.tsx create mode 100644 frontend/src/hooks/useInViewAnimation.ts create mode 100644 frontend/src/utils/animationConfig.ts diff --git a/frontend/src/components/Animation/AnimatedCard.tsx b/frontend/src/components/Animation/AnimatedCard.tsx new file mode 100644 index 0000000..dad0cc1 --- /dev/null +++ b/frontend/src/components/Animation/AnimatedCard.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; +import AnimationWrapper from "./AnimationWrapper"; + +interface AnimatedCardProps { + children: ReactNode; + delay?: number; + className?: string; +} + +export default function AnimatedCard(props: AnimatedCardProps) { + return ; +} \ No newline at end of file diff --git a/frontend/src/components/Animation/AnimationWrapper.tsx b/frontend/src/components/Animation/AnimationWrapper.tsx new file mode 100644 index 0000000..e0a9529 --- /dev/null +++ b/frontend/src/components/Animation/AnimationWrapper.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from "react"; +import { motion } from "framer-motion"; + +import { fadeIn, slideUp, slideLeft, scaleIn } from "@/utils/animationConfig"; +import { useInViewAnimation } from "@/hooks/useInViewAnimation"; + +type AnimationType = "fade" | "slideUp" | "slideLeft" | "scale"; + +interface AnimationWrapperProps { + children: ReactNode; + type?: AnimationType; + delay?: number; + duration?: number; + className?: string; + once?: boolean; +} + +const variantsMap = { + fade: fadeIn, + slideUp: slideUp, + slideLeft: slideLeft, + scale: scaleIn, +}; + +export default function AnimationWrapper({ + children, + type = "fade", + delay = 0, + duration, + className, + once = true, +}: AnimationWrapperProps) { + const { ref, isInView, prefersReducedMotion } = + useInViewAnimation({ once }); + + if (prefersReducedMotion) { + return
{children}
; + } + + const variants = variantsMap[type]; + + return ( + } + className={className} + variants={variants} + initial="hidden" + animate={isInView ? "visible" : "hidden"} + transition={{ + delay, + ...(duration ? { duration } : {}), + }} + > + {children} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/Animation/FadeIn.tsx b/frontend/src/components/Animation/FadeIn.tsx new file mode 100644 index 0000000..7f5dbec --- /dev/null +++ b/frontend/src/components/Animation/FadeIn.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; +import AnimationWrapper from "./AnimationWrapper"; + +interface FadeInProps { + children: ReactNode; + delay?: number; + duration?: number; + className?: string; +} + +export default function FadeIn(props: FadeInProps) { + return ; +} \ No newline at end of file diff --git a/frontend/src/components/Animation/SlideIn.tsx b/frontend/src/components/Animation/SlideIn.tsx new file mode 100644 index 0000000..b706ebf --- /dev/null +++ b/frontend/src/components/Animation/SlideIn.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; +import AnimationWrapper from "./AnimationWrapper"; + +interface SlideInProps { + children: ReactNode; + delay?: number; + duration?: number; + className?: string; +} + +export default function SlideIn(props: SlideInProps) { + return ; +} \ No newline at end of file diff --git a/frontend/src/hooks/useInViewAnimation.ts b/frontend/src/hooks/useInViewAnimation.ts new file mode 100644 index 0000000..32e17c3 --- /dev/null +++ b/frontend/src/hooks/useInViewAnimation.ts @@ -0,0 +1,28 @@ +import { useRef } from "react"; +import { useInView, useReducedMotion } from "framer-motion"; + +interface UseInViewAnimationOptions { + once?: boolean; + amount?: "some" | "all" | number; +} + +export const useInViewAnimation = ( + options: UseInViewAnimationOptions = {} +) => { + const { once = true, amount = 0.2 } = options; + + const ref = useRef(null); + + const isInView = useInView(ref, { + once, + amount, + }); + + const prefersReducedMotion = useReducedMotion(); + + return { + ref, + isInView, + prefersReducedMotion, + }; +}; \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 24f7362..92af3b8 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -2,6 +2,9 @@ import React from 'react'; import { motion } from 'framer-motion'; import { EarthTwin } from '@/components/EarthTwin'; import { MaterialIcon } from '@/components/MaterialIcon'; +import AnimatedCard from "@/components/Animation/AnimatedCard"; +import FadeIn from "@/components/Animation/FadeIn"; +import SlideIn from "@/components/Animation/SlideIn"; import { useSpaceSummary } from '@/hooks/useApi'; import { useCollisions } from '@/hooks/useApi'; import { useAgentRuns } from '@/hooks/useApi'; @@ -13,17 +16,17 @@ import { toast } from 'sonner'; const riskColor = (level: string) => { switch (level) { case 'CRITICAL': return 'border-l-status-emergency text-status-emergency'; - case 'HIGH': return 'border-l-status-emergency text-status-emergency'; - case 'MEDIUM': return 'border-l-status-warning text-status-warning'; - default: return 'border-l-primary-container text-primary-container'; + case 'HIGH': return 'border-l-status-emergency text-status-emergency'; + case 'MEDIUM': return 'border-l-status-warning text-status-warning'; + default: return 'border-l-primary-container text-primary-container'; } }; const riskBarColor = (level: string) => { switch (level) { case 'CRITICAL': case 'HIGH': return 'bg-status-emergency'; - case 'MEDIUM': return 'bg-status-warning'; - default: return 'bg-primary-container'; + case 'MEDIUM': return 'bg-status-warning'; + default: return 'bg-primary-container'; } }; @@ -51,66 +54,66 @@ const CollisionCardSkeleton = () => ( export const Dashboard: React.FC = () => { - const summary = useSpaceSummary(); + const summary = useSpaceSummary(); const collisions = useCollisions({ size: 5 }); - const agents = useAgentRuns({ size: 6 }); - const weather = useWeatherStatus(); + const agents = useAgentRuns({ size: 6 }); + const weather = useWeatherStatus(); const s = summary.data?.data; const w = weather.data?.data; const kpis = s ? [ - { - title: 'Tracked Satellites', - value: s.tracked_satellites.toLocaleString(), - icon: 'satellite_alt', - border: 'border-b-primary-container/40', - text: 'text-on-surface', - }, - { - title: 'Debris Objects', - value: s.debris_objects > 0 ? s.debris_objects.toLocaleString() : '—', - icon: 'delete_sweep', - border: 'border-b-on-surface-variant/40', - text: 'text-on-surface', - }, - { - title: 'Active Alerts', - value: s.active_alerts_count.toLocaleString(), - icon: 'dangerous', - border: s.active_alerts_count > 0 ? 'border-b-status-emergency glow-red' : 'border-b-on-surface-variant/40', - text: s.active_alerts_count > 0 ? 'text-status-emergency' : 'text-on-surface', - }, - { - title: 'Pred. Collisions', - value: s.predicted_collisions_count.toLocaleString(), - icon: 'cognition', - border: 'border-b-status-emergency/60', - text: 'text-on-surface', - }, - { - title: 'Space Weather', - value: s.space_weather_index, - subValue: w?.overall_severity, - icon: 'wb_sunny', - border: 'border-b-status-warning/40', - text: w?.overall_severity === 'EXTREME' ? 'text-status-emergency' : 'text-status-warning', - }, - { - title: 'Agent Runs', - value: s.active_agents_load > 0 ? s.active_agents_load.toLocaleString() : 'IDLE', - icon: 'bolt', - border: 'border-b-status-success/40', - text: 'text-status-success', - }, - ] + { + title: 'Tracked Satellites', + value: s.tracked_satellites.toLocaleString(), + icon: 'satellite_alt', + border: 'border-b-primary-container/40', + text: 'text-on-surface', + }, + { + title: 'Debris Objects', + value: s.debris_objects > 0 ? s.debris_objects.toLocaleString() : '—', + icon: 'delete_sweep', + border: 'border-b-on-surface-variant/40', + text: 'text-on-surface', + }, + { + title: 'Active Alerts', + value: s.active_alerts_count.toLocaleString(), + icon: 'dangerous', + border: s.active_alerts_count > 0 ? 'border-b-status-emergency glow-red' : 'border-b-on-surface-variant/40', + text: s.active_alerts_count > 0 ? 'text-status-emergency' : 'text-on-surface', + }, + { + title: 'Pred. Collisions', + value: s.predicted_collisions_count.toLocaleString(), + icon: 'cognition', + border: 'border-b-status-emergency/60', + text: 'text-on-surface', + }, + { + title: 'Space Weather', + value: s.space_weather_index, + subValue: w?.overall_severity, + icon: 'wb_sunny', + border: 'border-b-status-warning/40', + text: w?.overall_severity === 'EXTREME' ? 'text-status-emergency' : 'text-status-warning', + }, + { + title: 'Agent Runs', + value: s.active_agents_load > 0 ? s.active_agents_load.toLocaleString() : 'IDLE', + icon: 'bolt', + border: 'border-b-status-success/40', + text: 'text-status-success', + }, + ] : null; const conjunctions = collisions.data?.data ?? []; - const agentRuns = agents.data?.data ?? []; + const agentRuns = agents.data?.data ?? []; + - const decisions: Array = agentRuns .flatMap(run => (run.decisions ?? []).map(d => ({ ...d, runName: run.workflow_name })) @@ -131,41 +134,52 @@ export const Dashboard: React.FC = () => { {summary.isLoading ? Array(6).fill(0).map((_, i) => ) : summary.isError - ? ( -
- ⚠ Failed to connect to backend — please refresh or contact support if the issue persists. -
- ) - : kpis?.map((kpi, idx) => ( - -

- {kpi.title} -

-
-
- - {kpi.value} - - {kpi.subValue && ( - {kpi.subValue} - )} -
- + ? ( +
+ ⚠ Failed to connect to backend — please refresh or contact support if the issue persists.
- - )) + ) + : kpis?.map((kpi, idx) => ( + + +

+ {kpi.title} +

+ +
+
+ + {kpi.value} + + + {kpi.subValue && ( + + {kpi.subValue} + + )} +
+ + +
+
+
+ )) } {/* Bottom Timeline & Reasoning Stream */}
{/* Left (2/3): Live Collision Timeline */} -
-
+ +

LIVE COLLISION TIMELINE @@ -177,128 +191,127 @@ export const Dashboard: React.FC = () => {

-
- {collisions.isLoading ? ( -
- {[0, 1, 2].map(i => )} -
- ) : conjunctions.length === 0 ? ( -
-
- -

- NO ACTIVE CONJUNCTION RISKS DETECTED -

-

- Orbital catalog is clear — next scan in 10 minutes -

+
+ {collisions.isLoading ? ( +
+ {[0, 1, 2].map(i => )}
-
- ) : ( -
- {conjunctions.map((conj: Collision) => ( -
-
- -
-
-
-

- {conj.object_a?.name ?? '—'} vs {conj.object_b?.name ?? '—'} -

-

- {formatTCA(conj.tca)} -

+ ) : conjunctions.length === 0 ? ( +
+
+ +

+ NO ACTIVE CONJUNCTION RISKS DETECTED +

+

+ Orbital catalog is clear — next scan in 10 minutes +

+
+
+ ) : ( +
+ {conjunctions.map((conj: Collision) => ( +
+
+
-
-

- {(conj.probability * 100).toFixed(2)}% PROB -

-

{conj.risk_level}

+
+
+

+ {conj.object_a?.name ?? '—'} vs {conj.object_b?.name ?? '—'} +

+

+ {formatTCA(conj.tca)} +

+
+
+

+ {(conj.probability * 100).toFixed(2)}% PROB +

+

{conj.risk_level}

+
-
-
-
-
+
+
+
+
+

+ MISS DISTANCE: {conj.miss_distance_m < 1000 + ? `${conj.miss_distance_m.toFixed(0)}m` + : `${(conj.miss_distance_m / 1000).toFixed(2)} KM`} +

-

- MISS DISTANCE: {conj.miss_distance_m < 1000 - ? `${conj.miss_distance_m.toFixed(0)}m` - : `${(conj.miss_distance_m / 1000).toFixed(2)} KM`} -

-
- ))} -
- )} -
-
- - {/* Right (1/3): AI Agent Reasoning Stream */} -
-

- - AI REASONING STREAM -

-
- {agents.isLoading ? ( -
- {[0, 1, 2].map(i => ( -
-
-
-
- ))} -
- ) : decisions.length === 0 ? ( -
- -

- No agent runs active. -

-

- Agents activate automatically when conjunctions are detected. -

-
- ) : ( - decisions.map((d, i) => ( -
- - {d.agent_name} - - {d.reasoning} - - {new Date(d.created_at).toISOString().replace('T', ' ').substring(0, 19)}Z - + ))}
- )) - )} + )} +
-
+ -
+ {/* Right (1/3): AI Agent Reasoning Stream */ } +
+

+ + AI REASONING STREAM +

+
+ {agents.isLoading ? ( +
+ {[0, 1, 2].map(i => ( +
+
+
+
+ ))} +
+ ) : decisions.length === 0 ? ( +
+ +

+ No agent runs active. +

+

+ Agents activate automatically when conjunctions are detected. +

+
+ ) : ( + decisions.map((d, i) => ( +
+ + {d.agent_name} + + {d.reasoning} + + {new Date(d.created_at).toISOString().replace('T', ' ').substring(0, 19)}Z + +
+ )) + )}
+
+ + +
); }; export default Dashboard; diff --git a/frontend/src/utils/animationConfig.ts b/frontend/src/utils/animationConfig.ts new file mode 100644 index 0000000..ecb8245 --- /dev/null +++ b/frontend/src/utils/animationConfig.ts @@ -0,0 +1,58 @@ +import type { Variants } from "framer-motion"; +export const fadeIn: Variants = { + hidden: { + opacity: 0, + }, + visible: { + opacity: 1, + transition: { + duration: 0.5, + ease: "easeOut", + }, + }, +}; + +export const slideUp: Variants = { + hidden: { + opacity: 0, + y: 20, + }, + visible: { + opacity: 1, + y: 0, + transition: { + duration: 0.5, + ease: "easeOut", + }, + }, +}; + +export const slideLeft: Variants = { + hidden: { + opacity: 0, + x: 20, + }, + visible: { + opacity: 1, + x: 0, + transition: { + duration: 0.5, + ease: "easeOut", + }, + }, +}; + +export const scaleIn: Variants = { + hidden: { + opacity: 0, + scale: 0.95, + }, + visible: { + opacity: 1, + scale: 1, + transition: { + duration: 0.45, + ease: "easeOut", + }, + }, +}; \ No newline at end of file