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 };