diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/AnalysisAndVisualizations.spec.tsx b/context/app/static/js/components/home/AnalysisAndVisualizations/AnalysisAndVisualizations.spec.tsx
new file mode 100644
index 0000000000..33dbb06e94
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/AnalysisAndVisualizations.spec.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import SvgIcon from '@mui/material/SvgIcon';
+import { render, screen } from 'test-utils/functions';
+import AnalysisAndVisualizations from './AnalysisAndVisualizations';
+import { CLOUD_WORKSPACES_SLIDE, BIOMARKERS_SLIDE, VISUALIZE_DATA_SLIDE } from './config';
+
+// Mock SVG-based icons that fail in test environment due to SVG file mocking
+jest.mock('js/shared-styles/icons', () => {
+ const actual = jest.requireActual('js/shared-styles/icons/icons');
+ return {
+ ...Object.fromEntries(Object.keys(actual).map((key) => [key, SvgIcon])),
+ };
+});
+
+// Mock useMediaQuery to default to desktop
+jest.mock('@mui/material/useMediaQuery', () => ({
+ __esModule: true,
+ default: () => true,
+}));
+
+describe('AnalysisAndVisualizations', () => {
+ test('renders the section header', () => {
+ render();
+ expect(screen.getByText('Analysis and Visualizations')).toBeInTheDocument();
+ });
+
+ test('renders the introductory description', () => {
+ render();
+ expect(
+ screen.getByText(/See how researchers use HuBMAP's data and tools/),
+ ).toBeInTheDocument();
+ });
+
+ test('renders the Cloud Workspaces slide', () => {
+ render();
+ expect(screen.getByText(CLOUD_WORKSPACES_SLIDE.title)).toBeInTheDocument();
+ });
+
+ test('renders the Biomarkers slide', () => {
+ render();
+ expect(screen.getByText(BIOMARKERS_SLIDE.title)).toBeInTheDocument();
+ });
+
+ test('renders the Visualize Data slide', () => {
+ render();
+ expect(screen.getByText(VISUALIZE_DATA_SLIDE.sectionTitle)).toBeInTheDocument();
+ });
+
+ test('renders all three slides as regions', () => {
+ render();
+ const regions = screen.getAllByRole('region');
+ // Main section + 3 slide regions
+ expect(regions.length).toBeGreaterThanOrEqual(3);
+ });
+
+ test('Cloud Workspaces slide has Sign Up and Launch Workspaces buttons', () => {
+ render();
+ expect(screen.getByRole('link', { name: 'Sign Up' })).toHaveAttribute('href', '/register');
+ expect(screen.getByRole('link', { name: 'Launch Workspaces' })).toHaveAttribute('href', '/workspaces');
+ });
+
+ test('Biomarkers slide has Launch Advanced Search button', () => {
+ render();
+ expect(screen.getByRole('link', { name: 'Launch Advanced Search' })).toHaveAttribute('href', '/cells');
+ });
+
+ test('Visualize Data slide has all three view CTAs', () => {
+ render();
+ expect(screen.getByRole('link', { name: 'View Visualizations' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Visualize Cell Populations' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Explore Data' })).toBeInTheDocument();
+ });
+});
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/AnalysisAndVisualizations.tsx b/context/app/static/js/components/home/AnalysisAndVisualizations/AnalysisAndVisualizations.tsx
new file mode 100644
index 0000000000..363caf1a2c
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/AnalysisAndVisualizations.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Container from '@mui/material/Container';
+import { QueryStatsRounded } from '@mui/icons-material';
+
+import { SectionHeader } from 'js/pages/Home/style';
+import ParallaxSlide from './ParallaxSlide';
+import VisualizeDataSlide from './VisualizeDataSlide';
+import { CLOUD_WORKSPACES_SLIDE, BIOMARKERS_SLIDE, VISUALIZE_DATA_SLIDE } from './config';
+
+function AnalysisAndVisualizations() {
+ return (
+
+
+
+ Analysis and Visualizations
+
+
+ See how researchers use HuBMAP's data and tools to map human organs, cell types, and biomarkers.
+
+
+
+ {/* Parallax scroll container - tall enough for all 3 slides to scroll through */}
+
+
+
+
+
+
+ );
+}
+
+export default AnalysisAndVisualizations;
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxImage/ParallaxImage.spec.tsx b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxImage/ParallaxImage.spec.tsx
new file mode 100644
index 0000000000..cf4e5b057b
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxImage/ParallaxImage.spec.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { render, screen } from 'test-utils/functions';
+import ParallaxImage from './ParallaxImage';
+
+describe('ParallaxImage', () => {
+ test('renders image with correct alt text', () => {
+ render();
+ expect(screen.getByAltText('Test image')).toBeInTheDocument();
+ });
+
+ test('renders image with correct src', () => {
+ render();
+ const img = screen.getByAltText('Test image');
+ expect(img).toHaveAttribute('src', 'https://example.com/test.jpg');
+ });
+
+ test('applies lazy loading', () => {
+ render();
+ const img = screen.getByAltText('Test image');
+ expect(img).toHaveAttribute('loading', 'lazy');
+ });
+});
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxImage/ParallaxImage.tsx b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxImage/ParallaxImage.tsx
new file mode 100644
index 0000000000..f1999c54da
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxImage/ParallaxImage.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { useSpring } from '@react-spring/web';
+
+import { SlideImage } from '../types';
+import { ImageContainer, AnimatedImage } from './styles';
+
+interface ParallaxImageProps extends SlideImage {
+ /** Scroll progress value from 0 to 1 */
+ progress: number;
+ /** Whether user prefers reduced motion */
+ isReducedMotion: boolean;
+}
+
+function ParallaxImage({ src, alt, delay = 0, progress, isReducedMotion }: ParallaxImageProps) {
+ // Adjust progress to account for stagger delay
+ const adjustedProgress = Math.max(0, Math.min(1, (progress - delay) / (1 - delay)));
+
+ const springProps = useSpring({
+ opacity: isReducedMotion ? 1 : adjustedProgress,
+ transform: isReducedMotion ? 'translateY(0px)' : `translateY(${(1 - adjustedProgress) * 40}px)`,
+ config: { tension: 280, friction: 60 },
+ });
+
+ return (
+
+
+
+ );
+}
+
+export default React.memo(ParallaxImage);
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxImage/index.ts b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxImage/index.ts
new file mode 100644
index 0000000000..b190bb3196
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxImage/index.ts
@@ -0,0 +1 @@
+export { default } from './ParallaxImage';
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxImage/styles.ts b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxImage/styles.ts
new file mode 100644
index 0000000000..51a663899b
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxImage/styles.ts
@@ -0,0 +1,16 @@
+import { styled } from '@mui/material/styles';
+import { animated } from '@react-spring/web';
+
+export const ImageContainer = styled('div')({
+ position: 'relative',
+ width: '100%',
+});
+
+export const AnimatedImage = styled(animated.img)(({ theme }) => ({
+ width: '100%',
+ height: 'auto',
+ display: 'block',
+ borderRadius: theme.shape.borderRadius * 2,
+ boxShadow: theme.shadows[4],
+ willChange: 'opacity, transform',
+}));
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxSlide/ParallaxSlide.spec.tsx b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxSlide/ParallaxSlide.spec.tsx
new file mode 100644
index 0000000000..6f7d81ff40
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxSlide/ParallaxSlide.spec.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import SvgIcon from '@mui/material/SvgIcon';
+import { render, screen } from 'test-utils/functions';
+import ParallaxSlide from './ParallaxSlide';
+import { CLOUD_WORKSPACES_SLIDE, BIOMARKERS_SLIDE } from '../config';
+
+// Mock SVG-based icons that fail in test environment due to SVG file mocking
+jest.mock('js/shared-styles/icons', () => {
+ const actual = jest.requireActual('js/shared-styles/icons/icons');
+ return {
+ ...Object.fromEntries(Object.keys(actual).map((key) => [key, SvgIcon])),
+ };
+});
+
+describe('ParallaxSlide', () => {
+ test('renders slide title', () => {
+ render();
+ expect(screen.getByText(CLOUD_WORKSPACES_SLIDE.title)).toBeInTheDocument();
+ });
+
+ test('renders slide description', () => {
+ render();
+ expect(screen.getByText(CLOUD_WORKSPACES_SLIDE.description)).toBeInTheDocument();
+ });
+
+ test('renders bullet points', () => {
+ render();
+ CLOUD_WORKSPACES_SLIDE.bulletPoints!.forEach((point) => {
+ expect(screen.getByText(point)).toBeInTheDocument();
+ });
+ });
+
+ test('renders all CTA buttons with correct hrefs', () => {
+ render();
+ CLOUD_WORKSPACES_SLIDE.ctaButtons.forEach((button) => {
+ const link = screen.getByRole('link', { name: button.label });
+ expect(link).toHaveAttribute('href', button.href);
+ });
+ });
+
+ test('renders contained and outlined button variants', () => {
+ render();
+ const signUpButton = screen.getByRole('link', { name: 'Sign Up' });
+ const launchButton = screen.getByRole('link', { name: 'Launch Workspaces' });
+ expect(signUpButton.className).toContain('contained');
+ expect(launchButton.className).toContain('outlined');
+ });
+
+ test('renders images with correct alt text', () => {
+ render();
+ CLOUD_WORKSPACES_SLIDE.images.forEach((image) => {
+ expect(screen.getByAltText(image.alt)).toBeInTheDocument();
+ });
+ });
+
+ test('has correct aria-label for the region', () => {
+ render();
+ expect(screen.getByRole('region', { name: CLOUD_WORKSPACES_SLIDE.title })).toBeInTheDocument();
+ });
+
+ test('renders biomarkers slide with different layout', () => {
+ render();
+ expect(screen.getByText(BIOMARKERS_SLIDE.title)).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Launch Advanced Search' })).toHaveAttribute('href', '/cells');
+ });
+});
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxSlide/ParallaxSlide.tsx b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxSlide/ParallaxSlide.tsx
new file mode 100644
index 0000000000..57686a018b
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxSlide/ParallaxSlide.tsx
@@ -0,0 +1,77 @@
+import React, { useRef } from 'react';
+import Typography from '@mui/material/Typography';
+import Stack from '@mui/material/Stack';
+import Button from '@mui/material/Button';
+
+import { trackEvent } from 'js/helpers/trackers';
+import { useScrollProgress, usePrefersReducedMotion } from '../hooks';
+import { SlideConfig } from '../types';
+import ParallaxImage from '../ParallaxImage';
+import { ScrollRunway, StickySlideContent, GradientBackground, SlideGrid, TextContent, ImageGroup } from './styles';
+
+interface ParallaxSlideProps {
+ config: SlideConfig;
+ zIndex: number;
+}
+
+function ParallaxSlide({ config, zIndex }: ParallaxSlideProps) {
+ const runwayRef = useRef(null);
+ const progress = useScrollProgress(runwayRef);
+ const isReducedMotion = usePrefersReducedMotion();
+
+ const { theme, icon: Icon, title, description, bulletPoints, ctaButtons, images, layout } = config;
+
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ {description}
+
+ {bulletPoints && (
+
+ {bulletPoints.map((point) => (
+
+ {point}
+
+ ))}
+
+ )}
+
+ {ctaButtons.map((button) => (
+
+ ))}
+
+
+
+ {images.map((image) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+export default ParallaxSlide;
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxSlide/index.ts b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxSlide/index.ts
new file mode 100644
index 0000000000..82d9c21150
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxSlide/index.ts
@@ -0,0 +1 @@
+export { default } from './ParallaxSlide';
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxSlide/styles.ts b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxSlide/styles.ts
new file mode 100644
index 0000000000..7264f86667
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/ParallaxSlide/styles.ts
@@ -0,0 +1,119 @@
+import { styled } from '@mui/material/styles';
+import Box from '@mui/material/Box';
+
+import { ThemeColorKey } from '../types';
+
+// Map theme color keys to their accent90 palette keys
+export const accentColorMap: Record = {
+ info: 'info90',
+ warning: 'warning90',
+ success: 'success90',
+ error: 'primary90', // error uses primary90 in the theme
+};
+
+/**
+ * Outer wrapper that creates scroll runway for each slide.
+ * On desktop, this is taller than 100vh so the sticky inner content
+ * has scroll space for animations before the next slide covers it.
+ */
+interface ScrollRunwayProps {
+ $zIndex: number;
+}
+
+export const ScrollRunway = styled(Box)(({ theme, $zIndex }) => ({
+ position: 'relative',
+
+ [theme.breakpoints.up('md')]: {
+ // Extra height creates scroll space for image animations
+ height: '180vh',
+ zIndex: $zIndex,
+ },
+}));
+
+/**
+ * Inner content that sticks to the viewport while scrolling through the runway.
+ */
+export const StickySlideContent = styled(Box)(({ theme }) => ({
+ position: 'relative',
+ padding: theme.spacing(4, 2),
+
+ [theme.breakpoints.up('md')]: {
+ position: 'sticky',
+ top: 0,
+ height: '100vh',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+}));
+
+interface GradientBackgroundProps {
+ $theme: ThemeColorKey;
+}
+
+export const GradientBackground = styled('div')(({ theme, $theme }) => {
+ const accentKey = accentColorMap[$theme] as keyof typeof theme.palette.accent;
+ const accentColor = theme.palette.accent[accentKey];
+
+ return {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ zIndex: -1,
+ background: `linear-gradient(135deg, ${accentColor} 0%, ${theme.palette.common.white} 100%)`,
+ };
+});
+
+interface SlideGridProps {
+ $layout: 'text-left' | 'text-right';
+}
+
+export const SlideGrid = styled(Box)(({ theme, $layout }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(3),
+ width: '100%',
+ maxWidth: theme.breakpoints.values.lg,
+ margin: '0 auto',
+ padding: theme.spacing(0, 2),
+
+ [theme.breakpoints.up('md')]: {
+ display: 'grid',
+ gridTemplateColumns: $layout === 'text-left' ? '5fr 4fr' : '4fr 5fr',
+ gap: theme.spacing(6),
+ alignItems: 'start',
+ },
+}));
+
+/**
+ * Text column that sticks within the slide viewport on desktop,
+ * staying centered vertically while the user scrolls.
+ */
+export const TextContent = styled(Box)(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+
+ [theme.breakpoints.up('md')]: {
+ position: 'sticky',
+ top: '50%',
+ transform: 'translateY(-50%)',
+ },
+}));
+
+interface ImageGroupProps {
+ $layout: 'text-left' | 'text-right';
+}
+
+export const ImageGroup = styled(Box)(({ theme, $layout }) => ({
+ display: 'grid',
+ gridTemplateColumns: '1fr 1fr',
+ gap: theme.spacing(2),
+ position: 'relative',
+
+ // Ensure images column comes first on desktop when text-right layout
+ [theme.breakpoints.up('md')]: {
+ order: $layout === 'text-right' ? -1 : 0,
+ },
+}));
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/ViewSelector.tsx b/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/ViewSelector.tsx
new file mode 100644
index 0000000000..314730662e
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/ViewSelector.tsx
@@ -0,0 +1,200 @@
+import React, { useRef } from 'react';
+import Typography from '@mui/material/Typography';
+import Button from '@mui/material/Button';
+import Stack from '@mui/material/Stack';
+
+import { trackEvent } from 'js/helpers/trackers';
+import { ViewConfig } from '../types';
+import ParallaxImage from '../ParallaxImage';
+import {
+ ViewOptionContainer,
+ SwipeContainer,
+ SwipeTrack,
+ SwipePanel,
+ PaginationDot,
+ ImageArea,
+} from './styles';
+
+interface ViewSelectorProps {
+ views: ViewConfig[];
+ activeIndex: number;
+ onViewChange: (index: number) => void;
+ isDesktop: boolean;
+ progress: number;
+ isReducedMotion: boolean;
+}
+
+const SWIPE_THRESHOLD = 50;
+
+function DesktopViewSelector({ views, activeIndex, onViewChange }: Omit) {
+ return (
+
+ {views.map((view, index) => {
+ const Icon = view.icon;
+ const isActive = index === activeIndex;
+
+ return (
+ onViewChange(index)}
+ onClick={() => onViewChange(index)}
+ role="tab"
+ aria-selected={isActive}
+ aria-controls={`visualize-tabpanel-${view.id}`}
+ tabIndex={isActive ? 0 : -1}
+ onKeyDown={(e: React.KeyboardEvent) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ onViewChange((index + 1) % views.length);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ onViewChange((index - 1 + views.length) % views.length);
+ } else if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onViewChange(index);
+ }
+ }}
+ >
+
+
+
+ {view.title}
+
+
+
+ {view.description}
+
+
+
+ );
+ })}
+
+ );
+}
+
+function MobileViewSelector({
+ views,
+ activeIndex,
+ onViewChange,
+ progress,
+ isReducedMotion,
+}: Omit) {
+ const startX = useRef(0);
+ const currentX = useRef(0);
+
+ const handleTouchStart = (e: React.TouchEvent) => {
+ startX.current = e.touches[0].clientX;
+ currentX.current = e.touches[0].clientX;
+ };
+
+ const handleTouchMove = (e: React.TouchEvent) => {
+ currentX.current = e.touches[0].clientX;
+ };
+
+ const handleTouchEnd = () => {
+ const diff = startX.current - currentX.current;
+
+ if (Math.abs(diff) > SWIPE_THRESHOLD) {
+ if (diff > 0) {
+ // Swipe left - next view (wraps to first)
+ onViewChange((activeIndex + 1) % views.length);
+ } else {
+ // Swipe right - previous view (wraps to last)
+ onViewChange((activeIndex - 1 + views.length) % views.length);
+ }
+ }
+ };
+
+ return (
+ <>
+
+
+ {views.map((view) => {
+ const Icon = view.icon;
+ return (
+
+
+
+
+
+ {view.title}
+
+
+
+ {view.description}
+
+
+
+ {view.images.map((image) => (
+
+ ))}
+
+
+
+ );
+ })}
+
+
+
+ {views.map((view, index) => (
+ onViewChange(index)}
+ aria-label={`View ${view.title}`}
+ />
+ ))}
+
+ >
+ );
+}
+
+function ViewSelector(props: ViewSelectorProps) {
+ if (props.isDesktop) {
+ return ;
+ }
+ return ;
+}
+
+export default ViewSelector;
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/VisualizeDataSlide.spec.tsx b/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/VisualizeDataSlide.spec.tsx
new file mode 100644
index 0000000000..262c1836dc
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/VisualizeDataSlide.spec.tsx
@@ -0,0 +1,141 @@
+import React from 'react';
+import { render, screen, fireEvent } from 'test-utils/functions';
+import VisualizeDataSlide from './VisualizeDataSlide';
+import { VISUALIZE_DATA_SLIDE } from '../config';
+
+// Mock useMediaQuery to control desktop/mobile rendering
+const mockUseMediaQuery = jest.fn();
+jest.mock('@mui/material/useMediaQuery', () => ({
+ __esModule: true,
+ default: () => mockUseMediaQuery(),
+}));
+
+describe('VisualizeDataSlide', () => {
+ beforeEach(() => {
+ // Default to desktop
+ mockUseMediaQuery.mockReturnValue(true);
+ });
+
+ test('renders section title and description', () => {
+ render();
+ expect(screen.getByText(VISUALIZE_DATA_SLIDE.sectionTitle)).toBeInTheDocument();
+ expect(screen.getByText(VISUALIZE_DATA_SLIDE.sectionDescription)).toBeInTheDocument();
+ });
+
+ test('renders all view titles', () => {
+ render();
+ VISUALIZE_DATA_SLIDE.views.forEach((view) => {
+ expect(screen.getByText(view.title)).toBeInTheDocument();
+ });
+ });
+
+ test('renders all view descriptions', () => {
+ render();
+ VISUALIZE_DATA_SLIDE.views.forEach((view) => {
+ expect(screen.getByText(view.description)).toBeInTheDocument();
+ });
+ });
+
+ test('renders CTA buttons with correct hrefs', () => {
+ render();
+ VISUALIZE_DATA_SLIDE.views.forEach((view) => {
+ const link = screen.getByRole('link', { name: view.ctaButton.label });
+ expect(link).toHaveAttribute('href', view.ctaButton.href);
+ });
+ });
+
+ test('first view is active by default on desktop', () => {
+ render();
+ const firstViewButton = screen.getByRole('link', { name: 'View Visualizations' });
+ expect(firstViewButton.className).toContain('contained');
+ });
+
+ test('inactive view CTAs are outlined on desktop', () => {
+ render();
+ const secondViewButton = screen.getByRole('link', { name: 'Visualize Cell Populations' });
+ expect(secondViewButton.className).toContain('outlined');
+ });
+
+ test('hovering a view changes active view on desktop', () => {
+ render();
+
+ // Hover over the Cell Populations tab
+ const cellPopTab = screen.getByRole('tab', { name: /Cell Populations Viewer/i });
+ fireEvent.mouseEnter(cellPopTab);
+
+ // Cell Populations CTA should now be contained
+ const cellPopButton = screen.getByRole('link', { name: 'Visualize Cell Populations' });
+ expect(cellPopButton.className).toContain('contained');
+
+ // Single-Cell CTA should now be outlined
+ const singleCellButton = screen.getByRole('link', { name: 'View Visualizations' });
+ expect(singleCellButton.className).toContain('outlined');
+ });
+
+ test('keyboard navigation with arrow keys on desktop', () => {
+ render();
+
+ const firstTab = screen.getByRole('tab', { name: /Single-Cell/i });
+ firstTab.focus();
+
+ // Arrow down should activate the next view
+ fireEvent.keyDown(firstTab, { key: 'ArrowDown' });
+
+ const cellPopButton = screen.getByRole('link', { name: 'Visualize Cell Populations' });
+ expect(cellPopButton.className).toContain('contained');
+ });
+
+ test('has correct aria-label on region', () => {
+ render();
+ expect(screen.getByRole('region', { name: VISUALIZE_DATA_SLIDE.sectionTitle })).toBeInTheDocument();
+ });
+});
+
+describe('VisualizeDataSlide - Mobile', () => {
+ beforeEach(() => {
+ mockUseMediaQuery.mockReturnValue(false);
+ });
+
+ test('renders swipeable view with pagination dots', () => {
+ render();
+
+ // Should have pagination dots
+ VISUALIZE_DATA_SLIDE.views.forEach((view) => {
+ expect(screen.getByLabelText(`View ${view.title}`)).toBeInTheDocument();
+ });
+ });
+
+ test('clicking pagination dot changes view', () => {
+ render();
+
+ const secondDot = screen.getByLabelText(`View ${VISUALIZE_DATA_SLIDE.views[1].title}`);
+ fireEvent.click(secondDot);
+
+ // All views should still be visible in the swipe container on mobile
+ expect(screen.getByText(VISUALIZE_DATA_SLIDE.views[1].title)).toBeInTheDocument();
+ });
+
+ test('swipe left advances to next view', () => {
+ render();
+
+ const swipeArea = screen.getByRole('tabpanel');
+
+ fireEvent.touchStart(swipeArea, { touches: [{ clientX: 300 }] });
+ fireEvent.touchMove(swipeArea, { touches: [{ clientX: 100 }] });
+ fireEvent.touchEnd(swipeArea);
+
+ // The second dot should visually indicate active (we can check it exists)
+ expect(screen.getByLabelText(`View ${VISUALIZE_DATA_SLIDE.views[1].title}`)).toBeInTheDocument();
+ });
+
+ test('each mobile view shows its own images', () => {
+ render();
+
+ // All views' images should be in the DOM (just off-screen via translateX)
+ VISUALIZE_DATA_SLIDE.views.forEach((view) => {
+ view.images.forEach((image) => {
+ expect(screen.getByAltText(image.alt)).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/VisualizeDataSlide.tsx b/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/VisualizeDataSlide.tsx
new file mode 100644
index 0000000000..6786dc2123
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/VisualizeDataSlide.tsx
@@ -0,0 +1,86 @@
+import React, { useRef, useState } from 'react';
+import Typography from '@mui/material/Typography';
+import { useTheme } from '@mui/material/styles';
+import useMediaQuery from '@mui/material/useMediaQuery';
+import { useSpring } from '@react-spring/web';
+
+import { useScrollProgress, usePrefersReducedMotion } from '../hooks';
+import { MultiViewSlideConfig } from '../types';
+import { accentColorMap } from '../ParallaxSlide/styles';
+import ParallaxImage from '../ParallaxImage';
+import ViewSelector from './ViewSelector';
+import { VisualizeScrollRunway, VisualizeSlideContent, AnimatedGradientLayer, SlideContentGrid, ImageArea } from './styles';
+
+interface VisualizeDataSlideProps {
+ config: MultiViewSlideConfig;
+ zIndex: number;
+}
+
+function VisualizeDataSlide({ config, zIndex }: VisualizeDataSlideProps) {
+ const [activeViewIndex, setActiveViewIndex] = useState(0);
+ const runwayRef = useRef(null);
+ const theme = useTheme();
+ const isDesktop = useMediaQuery(theme.breakpoints.up('md'));
+ const progress = useScrollProgress(runwayRef);
+ const isReducedMotion = usePrefersReducedMotion();
+
+ const activeView = config.views[activeViewIndex];
+ const accentKey = accentColorMap[activeView.theme] as keyof typeof theme.palette.accent;
+ const targetColor = theme.palette.accent[accentKey];
+
+ const Icon = config.icon;
+
+ // Animate background gradient between view accent colors
+ const backgroundSpring = useSpring({
+ background: `linear-gradient(135deg, ${targetColor} 0%, ${theme.palette.common.white} 100%)`,
+ config: { duration: 400 },
+ });
+
+ return (
+
+
+
+
+ {/* Section title and description centered */}
+
+
+ {config.sectionTitle}
+
+
+ {config.sectionDescription}
+
+
+
+
+
+ {/* Desktop image area - shows active view's images */}
+ {isDesktop && (
+
+ {activeView.images.map((image) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+export default VisualizeDataSlide;
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/index.ts b/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/index.ts
new file mode 100644
index 0000000000..7a9e1325fb
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/index.ts
@@ -0,0 +1 @@
+export { default } from './VisualizeDataSlide';
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/styles.ts b/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/styles.ts
new file mode 100644
index 0000000000..69297a8260
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/VisualizeDataSlide/styles.ts
@@ -0,0 +1,130 @@
+import { styled } from '@mui/material/styles';
+import { animated } from '@react-spring/web';
+import Box from '@mui/material/Box';
+
+/**
+ * Outer scroll runway for the visualize slide.
+ */
+export const VisualizeScrollRunway = styled(Box)(({ theme }) => ({
+ position: 'relative',
+
+ [theme.breakpoints.up('md')]: {
+ height: '180vh',
+ },
+}));
+
+/**
+ * Inner sticky content for the visualize slide.
+ */
+export const VisualizeSlideContent = styled(Box)(({ theme }) => ({
+ position: 'relative',
+ padding: theme.spacing(4, 2),
+ overflow: 'hidden',
+
+ [theme.breakpoints.up('md')]: {
+ position: 'sticky',
+ top: 0,
+ height: '100vh',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+}));
+
+export const AnimatedGradientLayer = styled(animated.div)({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ zIndex: -1,
+});
+
+export const SlideContentGrid = styled(Box)(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(3),
+ width: '100%',
+ maxWidth: theme.breakpoints.values.lg,
+ margin: '0 auto',
+ padding: theme.spacing(0, 2),
+
+ [theme.breakpoints.up('md')]: {
+ display: 'grid',
+ gridTemplateColumns: '4fr 5fr',
+ gap: theme.spacing(4),
+ alignItems: 'center',
+ },
+}));
+
+interface ViewOptionContainerProps {
+ $isActive: boolean;
+}
+
+export const ViewOptionContainer = styled(Box)(({ theme, $isActive }) => ({
+ padding: theme.spacing(2),
+ borderRadius: theme.shape.borderRadius * 2,
+ cursor: 'pointer',
+ transition: theme.transitions.create(['background-color', 'box-shadow'], {
+ duration: theme.transitions.duration.short,
+ }),
+ backgroundColor: $isActive ? 'rgba(255, 255, 255, 0.7)' : 'transparent',
+ boxShadow: $isActive ? theme.shadows[1] : 'none',
+
+ '&:hover': {
+ backgroundColor: 'rgba(255, 255, 255, 0.5)',
+ },
+
+ '&:focus-visible': {
+ outline: `2px solid ${theme.palette.primary.main}`,
+ outlineOffset: 2,
+ },
+}));
+
+export const ImageArea = styled(Box)(({ theme }) => ({
+ display: 'grid',
+ gridTemplateColumns: '1fr 1fr',
+ gap: theme.spacing(2),
+ position: 'relative',
+ minHeight: 300,
+
+ [theme.breakpoints.up('md')]: {
+ minHeight: 400,
+ },
+}));
+
+export const SwipeContainer = styled(Box)({
+ width: '100%',
+ overflow: 'hidden',
+ position: 'relative',
+ touchAction: 'pan-y',
+});
+
+export const SwipeTrack = styled(Box)<{ $activeIndex: number }>(({ $activeIndex }) => ({
+ display: 'flex',
+ transform: `translateX(-${$activeIndex * 100}%)`,
+ transition: 'transform 0.3s ease-in-out',
+}));
+
+export const SwipePanel = styled(Box)(({ theme }) => ({
+ minWidth: '100%',
+ padding: theme.spacing(1),
+ boxSizing: 'border-box',
+}));
+
+export const PaginationDot = styled('button')<{ $isActive: boolean }>(({ theme, $isActive }) => ({
+ width: 10,
+ height: 10,
+ borderRadius: '50%',
+ border: 'none',
+ padding: 0,
+ cursor: 'pointer',
+ backgroundColor: $isActive ? theme.palette.text.primary : theme.palette.grey[400],
+ transition: theme.transitions.create('background-color'),
+
+ '&:focus-visible': {
+ outline: `2px solid ${theme.palette.primary.main}`,
+ outlineOffset: 2,
+ },
+}));
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/config.ts b/context/app/static/js/components/home/AnalysisAndVisualizations/config.ts
new file mode 100644
index 0000000000..32116552d4
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/config.ts
@@ -0,0 +1,177 @@
+import BarChartRounded from '@mui/icons-material/BarChartRounded';
+import ScatterPlotRounded from '@mui/icons-material/ScatterPlot';
+import DescriptionRounded from '@mui/icons-material/DescriptionRounded';
+import { SlideConfig, MultiViewSlideConfig } from './types';
+import { GeneIcon, WorkspacesIcon } from 'js/shared-styles/icons';
+
+export const CLOUD_WORKSPACES_SLIDE: SlideConfig = {
+ id: 'cloud-workspaces',
+ theme: 'info',
+ icon: WorkspacesIcon,
+ title: 'Analyze Datasets in Cloud-based Workspaces',
+ description:
+ 'Run your analyses directly in the browser with cloud-backed JupyterLab workspaces, eliminating the need for local setup or large dataset downloads.',
+ bulletPoints: [
+ 'Python and R environments',
+ 'Library of 15+ pre-built templates',
+ 'GPU-enabled compute options',
+ 'Workspace sharing for reproducible collaboration',
+ ],
+ ctaButtons: [
+ {
+ label: 'Sign Up',
+ href: '/register',
+ variant: 'contained',
+ trackingLabel: 'Cloud Workspaces / Sign Up',
+ },
+ {
+ label: 'Launch Workspaces',
+ href: '/workspaces',
+ variant: 'outlined',
+ trackingLabel: 'Cloud Workspaces / Launch Workspaces',
+ },
+ ],
+ images: [
+ {
+ src: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&auto=format',
+ alt: 'Workspace template card showing CODEX data clustering',
+ delay: 0,
+ },
+ {
+ src: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=800&auto=format',
+ alt: 'JupyterLab notebook with data analysis code',
+ delay: 0.15,
+ },
+ {
+ src: 'https://images.unsplash.com/photo-1504868584819-f8e8b4b6d7e3?w=800&auto=format',
+ alt: 'Interactive Vitessce visualization of spatial data',
+ delay: 0.3,
+ },
+ ],
+ layout: 'text-left',
+};
+
+export const BIOMARKERS_SLIDE: SlideConfig = {
+ id: 'biomarkers',
+ theme: 'warning',
+ icon: GeneIcon,
+ title: 'Discover More About Biomarkers and Cell Types',
+ description:
+ 'Search by gene, pathway, protein, or cell type to discover relevant datasets, compare patterns, and dive into rich visualizations.',
+ bulletPoints: [
+ 'Discover datasets that match your biological targets',
+ 'Compare patterns across studies',
+ 'Visualize gene expression, cell-type distributions, and other biologically meaningful signals at the dataset level',
+ ],
+ ctaButtons: [
+ {
+ label: 'Launch Advanced Search',
+ href: '/cells',
+ variant: 'contained',
+ trackingLabel: 'Biomarkers / Launch Advanced Search',
+ },
+ ],
+ images: [
+ {
+ src: 'https://images.unsplash.com/photo-1576086213369-97a306d36557?w=800&auto=format',
+ alt: 'Cell type distribution bar chart across kidney datasets',
+ delay: 0,
+ },
+ {
+ src: 'https://images.unsplash.com/photo-1582719471384-894fbb16e074?w=800&auto=format',
+ alt: 'UMAP scatterplot with cell ontology annotations',
+ delay: 0.15,
+ },
+ {
+ src: 'https://images.unsplash.com/photo-1530026405186-ed1f139313f8?w=800&auto=format',
+ alt: 'Expression by cell set violin plot',
+ delay: 0.3,
+ },
+ ],
+ layout: 'text-right',
+};
+
+export const VISUALIZE_DATA_SLIDE: MultiViewSlideConfig = {
+ id: 'visualize-data',
+ sectionTitle: 'Visualize HuBMAP Data',
+ icon: BarChartRounded,
+ sectionDescription:
+ 'Visualize HuBMAP data with interactive tools for single-cell biology, spatial imaging, cell populations, and metadata exploration.',
+ views: [
+ {
+ id: 'single-cell',
+ theme: 'success',
+ icon: BarChartRounded,
+ title: 'Single-Cell and Spatial Data Visualizations',
+ description: 'Visualize single-cell and spatial data with Vitessce',
+ ctaButton: {
+ label: 'View Visualizations',
+ href: '/search?mapped_data_types[0]=scRNA-seq%20%5BSalmon%5D&entity_type[0]=Dataset',
+ variant: 'contained',
+ trackingLabel: 'Visualize / View Visualizations',
+ },
+ images: [
+ {
+ src: 'https://images.unsplash.com/photo-1628595351029-c2bf17511435?w=800&auto=format',
+ alt: 'Vitessce spatial visualization with antigen list and heatmap',
+ delay: 0,
+ },
+ {
+ src: 'https://images.unsplash.com/photo-1532187863486-abf9dbad1b69?w=800&auto=format',
+ alt: 'Vitessce cell sets and spatial imaging view',
+ delay: 0.2,
+ },
+ ],
+ },
+ {
+ id: 'cell-populations',
+ theme: 'error',
+ icon: ScatterPlotRounded,
+ title: 'Cell Populations Viewer',
+ description: 'Visualize cell populations of organs.',
+ ctaButton: {
+ label: 'Visualize Cell Populations',
+ href: '/organ',
+ variant: 'contained',
+ trackingLabel: 'Visualize / Visualize Cell Populations',
+ },
+ images: [
+ {
+ src: 'https://images.unsplash.com/photo-1559757175-5700dde675bc?w=800&auto=format',
+ alt: 'Cell population counts histogram and dataset heatmap',
+ delay: 0,
+ },
+ {
+ src: 'https://images.unsplash.com/photo-1576086213369-97a306d36557?w=800&auto=format',
+ alt: 'Cell population tooltip with dataset and cell type details',
+ delay: 0.2,
+ },
+ ],
+ },
+ {
+ id: 'metadata',
+ theme: 'info',
+ icon: DescriptionRounded,
+ title: 'Metadata Exploration',
+ description: 'Dive deep into dataset metadata.',
+ ctaButton: {
+ label: 'Explore Data',
+ href: '/lineup',
+ variant: 'contained',
+ trackingLabel: 'Visualize / Explore Data',
+ },
+ images: [
+ {
+ src: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&auto=format',
+ alt: 'LineUp metadata table with column summaries and filtering',
+ delay: 0,
+ },
+ {
+ src: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=800&auto=format',
+ alt: 'LineUp data collection sorting and filtering interface',
+ delay: 0.2,
+ },
+ ],
+ },
+ ],
+};
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/hooks.spec.ts b/context/app/static/js/components/home/AnalysisAndVisualizations/hooks.spec.ts
new file mode 100644
index 0000000000..e4f936b2d1
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/hooks.spec.ts
@@ -0,0 +1,56 @@
+import { renderHook, act } from 'test-utils/functions';
+import { usePrefersReducedMotion } from './hooks';
+
+describe('usePrefersReducedMotion', () => {
+ const originalMatchMedia = window.matchMedia;
+
+ afterEach(() => {
+ window.matchMedia = originalMatchMedia;
+ });
+
+ test('returns false when user prefers motion', () => {
+ window.matchMedia = jest.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ }));
+
+ const { result } = renderHook(() => usePrefersReducedMotion());
+ expect(result.current).toBe(false);
+ });
+
+ test('returns true when user prefers reduced motion', () => {
+ window.matchMedia = jest.fn().mockImplementation((query: string) => ({
+ matches: query === '(prefers-reduced-motion: reduce)',
+ media: query,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ }));
+
+ const { result } = renderHook(() => usePrefersReducedMotion());
+ expect(result.current).toBe(true);
+ });
+
+ test('updates when preference changes', () => {
+ let changeHandler: ((e: { matches: boolean }) => void) | null = null;
+
+ window.matchMedia = jest.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ addEventListener: jest.fn((_event: string, handler: (e: { matches: boolean }) => void) => {
+ changeHandler = handler;
+ }),
+ removeEventListener: jest.fn(),
+ }));
+
+ const { result } = renderHook(() => usePrefersReducedMotion());
+ expect(result.current).toBe(false);
+
+ act(() => {
+ changeHandler?.({ matches: true });
+ });
+
+ expect(result.current).toBe(true);
+ });
+});
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/hooks.ts b/context/app/static/js/components/home/AnalysisAndVisualizations/hooks.ts
new file mode 100644
index 0000000000..f76254345e
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/hooks.ts
@@ -0,0 +1,68 @@
+import { useEffect, useState, useRef, useCallback, RefObject } from 'react';
+
+/**
+ * Returns a 0-1 scroll progress value for an element based on its position in the viewport.
+ * Progress is 0 when the element enters the viewport from the bottom,
+ * and approaches 1 as it scrolls past the top.
+ */
+export function useScrollProgress(ref: RefObject): number {
+ const [progress, setProgress] = useState(0);
+ const rafId = useRef(0);
+
+ const handleScroll = useCallback(() => {
+ if (rafId.current) {
+ cancelAnimationFrame(rafId.current);
+ }
+
+ rafId.current = requestAnimationFrame(() => {
+ const element = ref.current;
+ if (!element) return;
+
+ const rect = element.getBoundingClientRect();
+ const windowHeight = window.innerHeight;
+
+ // Calculate progress: 0 when element top is at bottom of viewport,
+ // 1 when element top has scrolled past the top of the viewport by its height
+ const rawProgress = (windowHeight - rect.top) / (windowHeight + rect.height);
+ setProgress(Math.min(1, Math.max(0, rawProgress)));
+ });
+ }, [ref]);
+
+ useEffect(() => {
+ handleScroll();
+ window.addEventListener('scroll', handleScroll, { passive: true });
+ return () => {
+ window.removeEventListener('scroll', handleScroll);
+ if (rafId.current) {
+ cancelAnimationFrame(rafId.current);
+ }
+ };
+ }, [handleScroll]);
+
+ return progress;
+}
+
+/**
+ * Detects the user's prefers-reduced-motion setting.
+ */
+export function usePrefersReducedMotion(): boolean {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ });
+
+ useEffect(() => {
+ if (typeof window.matchMedia !== 'function') return undefined;
+
+ const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
+
+ const handler = (e: MediaQueryListEvent) => {
+ setPrefersReducedMotion(e.matches);
+ };
+
+ mediaQuery.addEventListener('change', handler);
+ return () => mediaQuery.removeEventListener('change', handler);
+ }, []);
+
+ return prefersReducedMotion;
+}
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/index.ts b/context/app/static/js/components/home/AnalysisAndVisualizations/index.ts
new file mode 100644
index 0000000000..368e2ee79c
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/index.ts
@@ -0,0 +1 @@
+export { default } from './AnalysisAndVisualizations';
diff --git a/context/app/static/js/components/home/AnalysisAndVisualizations/types.ts b/context/app/static/js/components/home/AnalysisAndVisualizations/types.ts
new file mode 100644
index 0000000000..795c3f699b
--- /dev/null
+++ b/context/app/static/js/components/home/AnalysisAndVisualizations/types.ts
@@ -0,0 +1,47 @@
+import { MUIIcon } from 'js/shared-styles/icons/entityIconMap';
+
+export type ThemeColorKey = 'info' | 'warning' | 'success' | 'error';
+
+export interface SlideImage {
+ src: string;
+ alt: string;
+ /** Delay factor (0-1) for staggered animation. 0 = immediate, higher = later */
+ delay?: number;
+}
+
+export interface CTAButton {
+ label: string;
+ href: string;
+ variant: 'contained' | 'outlined';
+ trackingLabel: string;
+}
+
+export interface SlideConfig {
+ id: string;
+ theme: ThemeColorKey;
+ icon: MUIIcon;
+ title: string;
+ description: string;
+ bulletPoints?: string[];
+ ctaButtons: CTAButton[];
+ images: SlideImage[];
+ layout: 'text-left' | 'text-right';
+}
+
+export interface ViewConfig {
+ id: string;
+ theme: ThemeColorKey;
+ icon: MUIIcon;
+ title: string;
+ description: string;
+ ctaButton: CTAButton;
+ images: SlideImage[];
+}
+
+export interface MultiViewSlideConfig {
+ id: string;
+ sectionTitle: string;
+ icon: MUIIcon;
+ sectionDescription: string;
+ views: ViewConfig[];
+}
diff --git a/context/app/static/js/components/home/ExploreTools/CardGridContext.tsx b/context/app/static/js/components/home/ExploreTools/CardGridContext.tsx
deleted file mode 100644
index a5970e8c3b..0000000000
--- a/context/app/static/js/components/home/ExploreTools/CardGridContext.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { createContext, useContext } from 'js/helpers/context';
-import React, { PropsWithChildren } from 'react';
-
-interface CardGridContextProps extends PropsWithChildren {
- setExpandedCardIndex: (index: number | null) => void;
- expandedCardIndex: number | null;
- cardCount: number;
-}
-
-const CardGridContext = createContext('CardGridContext');
-
-function CardGridContextProvider({ children, ...value }: CardGridContextProps) {
- return {children};
-}
-
-const useCardGridContext = () => useContext(CardGridContext);
-
-export { CardGridContextProvider, useCardGridContext };
diff --git a/context/app/static/js/components/home/ExploreTools/ExploreTools.tsx b/context/app/static/js/components/home/ExploreTools/ExploreTools.tsx
deleted file mode 100644
index 153177b864..0000000000
--- a/context/app/static/js/components/home/ExploreTools/ExploreTools.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import React, { useState } from 'react';
-
-import Grid from '@mui/material/Grid2';
-import { entityIconMap } from 'js/shared-styles/icons/entityIconMap';
-import theme from 'js/theme/theme';
-import { VisualizationIcon } from 'js/shared-styles/icons';
-import { ToolsCard } from './ToolsCard';
-import { CardGridContextProvider } from './CardGridContext';
-import { ToolDescription } from './ToolDescription';
-import { makeGridTemplateColumns } from './utils';
-
-const workspaceCTAAuthenticated = {
- ctaText: 'View Your Workspaces',
- ctaLink: '/workspaces',
- ctaIcon: ,
-};
-
-const workspaceCTAGuest = {
- ctaText: 'Sign in',
- ctaLink: '/login',
-};
-
-const cards = [
- {
- title: 'Analyze data in Workspaces',
- icon: ,
- src: `${CDN_URL}/v2/explore-tools/tools_workspaces.png`,
- alt: 'A screenshot of a remote Jupyter and RStudio environment.',
- subtitle: 'Load datasets into an interactive JupyterLab Python and R analysis environment.',
- checklistItems: [
- 'No need to download data.',
- 'Use the provided code templates to get started with HuBMAP data.',
- 'The Workspaces feature is available once you sign in.',
- ],
- ...(isAuthenticated ? workspaceCTAAuthenticated : workspaceCTAGuest),
- },
- {
- title: 'Visualize data in Vitessce',
- icon: ,
- src: `${CDN_URL}/v2/explore-tools/tools_vitessce.png`,
- alt: 'A screenshot of a Vitessce visualization with a scatterplot, spatial view, and heatmap.',
- subtitle: 'Explore spatial and single-cell multi-modal datasets with interactive components.',
- checklistItems: ['Scatterplots', 'Heatmaps', 'Spatial Views', 'Genome Browser Tracks', 'Various Statistical Plots'],
- },
- {
- title: 'Explore biomarkers & cell types',
- icon: ,
- src: `${CDN_URL}/v2/explore-tools/tools_mcquery.png`,
- alt: 'A screenshot of the results of a lookup for cells that match a specific gene expression pattern.',
- subtitle: 'Discover new insights about genes, proteins or cell type related to HuBMAP data.',
- checklistItems: ['Transcriptomic', 'Epigenomic', 'Proteomic', 'Cell Types'],
- ctaText: 'Advanced Query',
- ctaLink: '/search/biomarkers-cell-types',
- },
-];
-
-export default function ExploreTools() {
- const [expandedCardIndex, setExpandedCardIndex] = useState(null);
-
- const gridTemplateColumns = makeGridTemplateColumns(cards, expandedCardIndex);
-
- const resetExpandedCardIndex = () => {
- setExpandedCardIndex(null);
- };
- return (
-
- {
- if (!e.currentTarget.contains(e.relatedTarget as Node)) resetExpandedCardIndex();
- }}
- >
- {cards.map(({ title, icon, src, alt, ...card }, index) => (
-
-
-
- ))}
-
-
- );
-}
diff --git a/context/app/static/js/components/home/ExploreTools/ToolDescription.tsx b/context/app/static/js/components/home/ExploreTools/ToolDescription.tsx
deleted file mode 100644
index 57faff9614..0000000000
--- a/context/app/static/js/components/home/ExploreTools/ToolDescription.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import React from 'react';
-import Box from '@mui/material/Box';
-import Button from '@mui/material/Button';
-import Typography from '@mui/material/Typography';
-import { CheckList } from './styles';
-
-interface ToolDescriptionProps {
- subtitle: string;
- checklistItems: string[];
- ctaText?: string;
- ctaLink?: string;
- ctaIcon?: React.ReactNode;
-}
-
-export function ToolDescription({ subtitle, checklistItems, ctaText, ctaLink, ctaIcon }: ToolDescriptionProps) {
- return (
-
- {subtitle}
-
- {checklistItems.map((item) => (
-
- {item}
-
- ))}
-
- {ctaText && ctaLink && (
-
- )}
-
- );
-}
diff --git a/context/app/static/js/components/home/ExploreTools/ToolsCard.tsx b/context/app/static/js/components/home/ExploreTools/ToolsCard.tsx
deleted file mode 100644
index c395788f7a..0000000000
--- a/context/app/static/js/components/home/ExploreTools/ToolsCard.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import React, { PropsWithChildren } from 'react';
-import { animated, useTransition } from '@react-spring/web';
-import Paper from '@mui/material/Paper';
-import Typography from '@mui/material/Typography';
-import Stack from '@mui/material/Stack';
-import Grid from '@mui/material/Grid2';
-import Box from '@mui/material/Box';
-import { useEventCallback } from '@mui/material/utils';
-
-import { trackEvent } from 'js/helpers/trackers';
-import { useIsDesktop, useIsMobile } from 'js/hooks/media-queries';
-
-import { useCardGridContext } from './CardGridContext';
-import { StyledImg } from './styles';
-
-interface ToolsCardProps extends PropsWithChildren {
- title: string;
- index: number;
- src: string;
- icon?: React.ReactNode;
- alt: string;
-}
-
-function getFlexAlignment(index: number, cardCount: number) {
- const isFirstCard = index === 0;
- if (isFirstCard) {
- return 'flex-start';
- }
- const isLastCard = index === cardCount - 1;
- if (isLastCard) {
- return 'flex-end';
- }
- return 'center';
-}
-
-export function ToolsCard({ title, children: description, index, src, icon, alt }: ToolsCardProps) {
- const { expandedCardIndex, setExpandedCardIndex, cardCount } = useCardGridContext();
- const isDesktop = useIsDesktop();
- const isMobile = useIsMobile();
- const isTablet = !isDesktop && !isMobile;
- const isExpanded = expandedCardIndex === index || isTablet;
- const setIsExpanded = () => {
- setExpandedCardIndex(index);
- };
- const transition = useTransition(isExpanded && !isMobile, {
- duration: 200,
- from: { maxHeight: 0, opacity: 0, width: 0 },
- enter: { maxHeight: 'auto', opacity: 1, width: 'fit-content' },
- leave: { maxHeight: 0, opacity: 0, width: 0 },
- });
- const justifyContent = isDesktop ? getFlexAlignment(index, cardCount) : 'stretch';
-
- const handleExpand = useEventCallback(() => {
- trackEvent({
- category: 'Homepage',
- action: 'Explore Tools & Resources',
- label: title,
- });
- setIsExpanded();
- });
-
- return (
-
-
-
-
- {transition((style, isOpen) => {
- if (!isOpen || isMobile) {
- return null;
- }
- return {description};
- })}
-
-
- {icon}
-
- {title}
-
-
- {isMobile && description}
-
-
- );
-}
diff --git a/context/app/static/js/components/home/ExploreTools/index.ts b/context/app/static/js/components/home/ExploreTools/index.ts
deleted file mode 100644
index 4487f9f485..0000000000
--- a/context/app/static/js/components/home/ExploreTools/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import ExploreTools from './ExploreTools';
-
-export default ExploreTools;
diff --git a/context/app/static/js/components/home/ExploreTools/styles.ts b/context/app/static/js/components/home/ExploreTools/styles.ts
deleted file mode 100644
index b69c1a2e51..0000000000
--- a/context/app/static/js/components/home/ExploreTools/styles.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { styled } from '@mui/material/styles';
-
-export const CheckList = styled('ul')(({ theme }) => ({
- '& li': {
- listStyleImage: `url(data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%20-6%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M8.79506%2016.375L5.32506%2012.905C5.13823%2012.7178%204.88458%2012.6125%204.62006%2012.6125C4.35554%2012.6125%204.10189%2012.7178%203.91506%2012.905C3.52506%2013.295%203.52506%2013.925%203.91506%2014.315L8.09506%2018.495C8.48506%2018.885%209.11506%2018.885%209.50506%2018.495L20.0851%207.91501C20.4751%207.52501%2020.4751%206.89501%2020.0851%206.50501C19.8982%206.31776%2019.6446%206.21252%2019.3801%206.21252C19.1155%206.21252%2018.8619%206.31776%2018.6751%206.50501L8.79506%2016.375Z%22%20fill%3D%22${encodeURIComponent(
- theme.palette.success.main,
- )}%22%2F%3E%3C%2Fsvg%3E)`,
- listStylePosition: 'outside',
- '&::marker': {
- width: '1rem',
- height: '1rem',
- },
- },
-}));
-
-export const StyledImg = styled('img')({
- aspectRatio: '3 / 4',
- maxHeight: '520px',
-});
diff --git a/context/app/static/js/components/home/ExploreTools/utils.spec.ts b/context/app/static/js/components/home/ExploreTools/utils.spec.ts
deleted file mode 100644
index 7e457fbf51..0000000000
--- a/context/app/static/js/components/home/ExploreTools/utils.spec.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { makeGridTemplateColumns } from './utils';
-
-const testCards = [1, 2, 3];
-
-const unexpandedResult = '1fr 1fr 1fr';
-const firstExpandedResult = '3fr 1fr 1fr';
-const secondExpandedResult = '1fr 3fr 1fr';
-const thirdExpandedResult = '1fr 1fr 3fr';
-
-describe('makeGridTemplateColumns', () => {
- it('should return 1fr 1fr 1fr when expandedCardIndex is null', () => {
- expect(makeGridTemplateColumns(testCards, null)).toEqual(unexpandedResult);
- });
- it.each([
- [0, firstExpandedResult],
- [1, secondExpandedResult],
- [2, thirdExpandedResult],
- ])('should return %s when expandedCardIndex is %s', (index, result) => {
- expect(makeGridTemplateColumns(testCards, index)).toEqual(result);
- });
-});
diff --git a/context/app/static/js/components/home/ExploreTools/utils.ts b/context/app/static/js/components/home/ExploreTools/utils.ts
deleted file mode 100644
index 3f0c0cb176..0000000000
--- a/context/app/static/js/components/home/ExploreTools/utils.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export function makeGridTemplateColumns(cards: unknown[], expandedCardIndex: number | null) {
- if (expandedCardIndex === null) {
- return '1fr 1fr 1fr';
- }
- const expandedColSize = '3fr';
- const otherColSize = '1fr';
-
- return cards
- .reduce((acc, _, idx) => `${acc} ${idx === expandedCardIndex ? expandedColSize : otherColSize}`, '')
- .trim();
-}
diff --git a/context/app/static/js/pages/Home/Home.tsx b/context/app/static/js/pages/Home/Home.tsx
index 7a5944bf77..be72bfc596 100644
--- a/context/app/static/js/pages/Home/Home.tsx
+++ b/context/app/static/js/pages/Home/Home.tsx
@@ -6,14 +6,14 @@ import Typography from '@mui/material/Typography';
import HuBMAPDatasetsChart from 'js/components/home/HuBMAPDatasetsChart';
import DataUseGuidelines from 'js/components/home/DataUseGuidelines';
import ResearchPoweredByHuBMAP from 'js/components/home/ResearchPoweredByHuBMAP';
+import AnalysisAndVisualizations from 'js/components/home/AnalysisAndVisualizations';
import { useDownloadImage } from 'js/hooks/useDownloadImage';
import { trackEvent } from 'js/helpers/trackers';
import DownloadButton from 'js/shared-styles/buttons/DownloadButton';
-import { VisualizationIcon } from 'js/shared-styles/icons';
import EntityCounts from 'js/components/home/EntityCounts';
import Hero from 'js/components/home/Hero';
-import { LowerContainerGrid } from './style';
+import { LowerContainerGrid, BottomLowerGrid } from './style';
import { BiotechRounded, BuildRounded, FormatQuoteRounded, PrivacyTipRounded } from '@mui/icons-material';
import RelatedToolsAndResources from 'js/components/home/RelatedToolsAndResources';
import { entityIconMap } from 'js/shared-styles/icons/entityIconMap';
@@ -68,22 +68,10 @@ function Home() {
)}
-
-
- Coming soon.
-
-
-
+
+
+
+
@@ -97,7 +85,7 @@ function Home() {
-
+
>
);
}
diff --git a/context/app/static/js/pages/Home/style.tsx b/context/app/static/js/pages/Home/style.tsx
index 20daf0818e..5231dffae8 100644
--- a/context/app/static/js/pages/Home/style.tsx
+++ b/context/app/static/js/pages/Home/style.tsx
@@ -8,25 +8,15 @@ import { MUIIcon } from 'js/shared-styles/icons/entityIconMap';
const LowerContainerGrid = styled(Container)(({ theme }) => ({
display: 'grid',
gridGap: theme.spacing(3),
- gridTemplateAreas: `
- "analysis-and-visualizations"
- "publications"
- "testimonials"
- "guidelines"
- "related-tools-and-resources"
- `,
- marginBottom: theme.spacing(5),
+ gridTemplateAreas: '"bar-chart"',
+ marginBottom: theme.spacing(3),
+})) as typeof Container;
- [theme.breakpoints.up('md')]: {
- gridTemplateAreas: `
- "bar-chart"
- "analysis-and-visualizations"
- "publications"
- "testimonials"
- "guidelines"
- "related-tools-and-resources"
- `,
- },
+const BottomLowerGrid = styled(Container)(({ theme }) => ({
+ display: 'grid',
+ gridGap: theme.spacing(3),
+ gridTemplateAreas: '"research-powered-by-hubmap" "testimonials" "guidelines" "related-tools-and-resources"',
+ marginBottom: theme.spacing(5),
})) as typeof Container;
const SectionHeaderInternal = styled(Typography)(({ theme }) => ({
@@ -54,4 +44,4 @@ const OffsetDatasetsHeader = styled(SectionHeader)({
scrollMarginTop: `${headerHeight + 10}px`,
}) as typeof SectionHeader;
-export { LowerContainerGrid, SectionHeader, OffsetDatasetsHeader };
+export { LowerContainerGrid, BottomLowerGrid, SectionHeader, OffsetDatasetsHeader };