diff --git a/src/components/NavbarAuthControls.tsx b/src/components/NavbarAuthControls.tsx index 675b3bc0d..be2b2fa22 100644 --- a/src/components/NavbarAuthControls.tsx +++ b/src/components/NavbarAuthControls.tsx @@ -6,7 +6,7 @@ import { ChatCircleIcon } from '@phosphor-icons/react/ChatCircle' import { SignInIcon } from '@phosphor-icons/react/SignIn' import { SignOutIcon } from '@phosphor-icons/react/SignOut' import { SparkleIcon } from '@phosphor-icons/react/Sparkle' -import { Link, useNavigate } from '@tanstack/react-router' +import { ClientOnly, Link, useNavigate } from '@tanstack/react-router' import { twMerge } from 'tailwind-merge' import { Authenticated, @@ -81,7 +81,7 @@ export function NavbarAuthControls({ className }: NavbarAuthControlsProps) { ) return ( - <> + {loadingPlaceholder} {loginButton} @@ -94,7 +94,7 @@ export function NavbarAuthControls({ className }: NavbarAuthControlsProps) { /> - + ) } @@ -150,7 +150,7 @@ export function MobileNavbarAuthControls({ ) return ( - <> + {loadingPlaceholder} {signIn} @@ -214,6 +214,6 @@ export function MobileNavbarAuthControls({ Sign Out - + ) } diff --git a/src/components/landing/ChartsCatalogGallery.tsx b/src/components/landing/ChartsCatalogGallery.tsx index 87f7a2c7a..645f76138 100644 --- a/src/components/landing/ChartsCatalogGallery.tsx +++ b/src/components/landing/ChartsCatalogGallery.tsx @@ -8,92 +8,6 @@ import { shuffleWithSeed } from '~/utils/utils' type ChartsLandingCatalog = Awaited> type CatalogCase = ChartsLandingCatalog['cases'][number] -export const chartsLandingHeroCaseIds = [ - '70-composed-chart', - '101-sunburst', - '127-shadcn-dashboard', -] as const - -export function CatalogChartsHero({ - catalog, -}: { - catalog: ChartsLandingCatalog -}) { - const casesById = new Map( - catalog.cases.map((catalogCase) => [catalogCase.id, catalogCase]), - ) - const heroCases = chartsLandingHeroCaseIds.flatMap((caseId) => { - const catalogCase = casesById.get(caseId) - return catalogCase ? [catalogCase] : [] - }) - - if (heroCases.length === 0) return null - - return ( -
-
- {heroCases.map((catalogCase, index) => ( - - ))} -
-
- ) -} - -function HeroChartTile({ - catalogCase, - featured, - revision, -}: { - catalogCase: CatalogCase - featured: boolean - revision: string -}) { - return ( -
-
-
- -
- -
- -
-

- {catalogCase.title} -

-

- {catalogCase.family} -

-
-
- ) -} - export function ChartsCatalogGallery({ catalog, orderSeed, diff --git a/src/components/landing/ChartsHeroBackground.tsx b/src/components/landing/ChartsHeroBackground.tsx new file mode 100644 index 000000000..fd5383be9 --- /dev/null +++ b/src/components/landing/ChartsHeroBackground.tsx @@ -0,0 +1,347 @@ +import * as React from 'react' +import { ClientOnly } from '@tanstack/react-router' +import { crosshair, defineChart, dot } from '@tanstack/charts' +import { Chart } from '@tanstack/charts/react/core' +import { motion } from '@tanstack/charts/motion' +import { tooltip } from '@tanstack/charts/tooltip' +import { scaleLinear } from '@tanstack/charts/scales/linear' +import { scaleOrdinal } from '@tanstack/charts/scales/ordinal' + +// Stable illustrative points for the hero background. +const colors = ['#ec4899', '#8b5cf6', '#3b82f6', '#14b8a6', '#f97316'] +const foregroundColors = ['#f39bc4', '#b59bf2', '#92b9fa', '#82d7ce', '#fbc18e'] +const renderer = motion({ + initial: false, + transition: { type: 'spring', stiffness: 220, damping: 26 }, +}) +const points = Array.from({ length: 2400 }, (_, id) => { + const x = ((id * 73) % 2401) / 2401 + const spread = Math.sin(id * 127.1) * Math.cos(id * 311.7) + const y = 0.08 + x * 0.84 + spread * (0.32 + x * 0.5) + const sizeSample = + (((id * 97) % 2401) / 2400 + + ((id * 193 + 419) % 2401) / 2400 + + ((id * 389 + 811) % 2401) / 2400) / + 3 + const color = x < 0.35 ? colors[0] : x < 0.6 ? colors[1] : colors[2] + + return { + id, + x, + y, + radius: (2 + sizeSample * 16) * (1 - Math.abs(spread) * 0.75), + color: id % 29 === 0 ? colors[3] : id % 31 === 0 ? colors[4] : color, + } +}) + +function createLayers(pointCount: number) { + const sortedPoints = points + .slice(0, pointCount) + .sort((a, b) => a.radius - b.radius) + return [ + { + depth: 0.9, + size: 0.8, + opacity: 0.35, + blur: 5, + duration: 2200, + start: 0, + end: 0.06, + }, + { + depth: 1.6, + size: 0.9, + opacity: 0.5, + blur: 0.5, + duration: 2000, + start: 0.06, + end: 0.3, + }, + { + depth: 2.6, + size: 1, + opacity: 0.65, + blur: 0, + duration: 1800, + start: 0.3, + end: 0.7, + }, + { + depth: 5.5, + size: 1.15, + opacity: 0.8, + blur: 0.5, + duration: 1600, + start: 0.7, + end: 0.94, + }, + { + depth: 10, + size: 1.5, + opacity: 0.9, + blur: 8, + duration: 1400, + start: 0.94, + end: 1, + }, + ].map((layer, index) => { + const layerPoints = sortedPoints + .slice( + Math.round(pointCount * layer.start), + Math.round(pointCount * layer.end), + ) + .map((point) => ({ + ...point, + y: + index === 4 + ? 0.08 + + point.x * 0.84 + + (((point.id * 157 + 653) % 2401) / 2400 - 0.5) * + (0.45 + point.x * 0.45) + : point.y, + radius: point.radius * layer.size, + color: + index >= 3 + ? foregroundColors[colors.indexOf(point.color)] + : point.color, + })) + return { + ...layer, + points: layerPoints, + definition: defineChart({ + marks: [ + dot(layerPoints, { + x: 'x', + y: 'y', + key: 'id', + r: 'radius', + z: 'color', + fillOpacity: layer.opacity, + }), + ...(index === 2 + ? [ + crosshair({ + motion: false, + stroke: 'var(--color-text-primary)', + strokeOpacity: 0.3, + strokeDasharray: '4 4', + marker: { radius: 4, fill: 'var(--color-text-primary)' }, + }), + ] + : []), + ], + scales: { + x: { scale: scaleLinear().domain([0, 1]), axis: false }, + y: { scale: scaleLinear().domain([0, 1]), axis: false }, + }, + margin: 0, + color: { + scale: scaleOrdinal() + .domain([...colors, ...foregroundColors]) + .range([...colors, ...foregroundColors]), + }, + theme: { background: 'transparent' }, + keyboard: false, + pointer: index === 2, + tooltip: + index === 2 + ? { + use: tooltip, + sticky: false, + format: ({ datum }) => + `Point ${datum.id + 1}\nX: ${datum.x.toFixed(2)} ยท Y: ${datum.y.toFixed(2)}`, + } + : undefined, + }), + } + }) +} + +export function ChartsHeroBackground() { + return ( + + ) +} + +function ClientHeroChart() { + const containerRef = React.useRef(null) + const [pointCount, setPointCount] = React.useState(0) + const layers = React.useMemo( + () => (pointCount ? createLayers(pointCount) : []), + [pointCount], + ) + + React.useEffect(() => { + const container = containerRef.current + if (!container) return + const observer = new ResizeObserver(([entry]) => { + setPointCount( + Math.min( + 2400, + Math.max(200, Math.round(entry.contentRect.width / 100) * 100), + ), + ) + }) + observer.observe(container) + return () => observer.disconnect() + }, []) + const [ready, setReady] = React.useState(false) + const [visible, setVisible] = React.useState(false) + const parallaxRefs = React.useRef>([]) + + React.useEffect(() => { + if (!ready) return + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => setVisible(true)) + }) + return () => cancelAnimationFrame(frame) + }, [ready]) + + const hasRendered = React.useRef(false) + const onRender = React.useCallback(() => { + if (hasRendered.current) return + hasRendered.current = true + setReady(true) + }, []) + + React.useEffect(() => { + const container = containerRef.current + if (!container || !layers.length) return + let inView = true + const motionAllowed = window.matchMedia( + '(prefers-reduced-motion: no-preference) and (pointer: fine)', + ) + let frame = 0 + let previousTime = 0 + let targetX = 0 + let targetY = 0 + const positions = layers.map(() => ({ x: 0, y: 0 })) + const animate = (now: number) => { + const elapsed = Math.min(64, previousTime ? now - previousTime : 16) + previousTime = now + let moving = false + parallaxRefs.current.forEach((element, index) => { + if (!element) return + const position = positions[index] + const layer = layers[index] + const amount = 1 - Math.exp(-elapsed / (layer.duration * 0.25)) + position.x += (targetX - position.x) * amount + position.y += (targetY - position.y) * amount + if ( + Math.abs(targetX - position.x) + Math.abs(targetY - position.y) > + 0.0005 + ) + moving = true + const x = position.x * layer.depth + const y = position.y * layer.depth + element.style.transform = `perspective(1400px) translate3d(${x * 12}px, ${y * 10}px, 0) rotateX(${-y * 1.5}deg) rotateY(${x * 1.5}deg) scale(1.035)` + }) + frame = moving ? requestAnimationFrame(animate) : 0 + if (!moving) previousTime = 0 + } + const start = () => { + if (!frame && inView && !document.hidden && motionAllowed.matches) + frame = requestAnimationFrame(animate) + } + const reset = () => { + targetX = targetY = 0 + if (motionAllowed.matches) start() + else { + cancelAnimationFrame(frame) + frame = previousTime = 0 + positions.forEach((position) => { + position.x = position.y = 0 + }) + parallaxRefs.current.forEach((element) => { + if (element) + element.style.transform = 'perspective(1400px) scale(1.035)' + }) + } + } + const move = (event: PointerEvent) => { + if ( + !inView || + document.hidden || + !motionAllowed.matches || + event.pointerType !== 'mouse' + ) + return + targetX = (event.clientX / window.innerWidth - 0.5) * 2 + targetY = (event.clientY / window.innerHeight - 0.5) * 2 + start() + } + const pause = () => { + cancelAnimationFrame(frame) + frame = previousTime = 0 + } + const visibility = () => { + if (document.hidden) pause() + else start() + } + const observer = new IntersectionObserver(([entry]) => { + inView = entry.isIntersecting + if (inView) start() + else pause() + }) + observer.observe(container) + document.addEventListener('visibilitychange', visibility) + window.addEventListener('pointermove', move) + document.documentElement.addEventListener('pointerleave', reset) + motionAllowed.addEventListener('change', reset) + return () => { + cancelAnimationFrame(frame) + observer.disconnect() + document.removeEventListener('visibilitychange', visibility) + window.removeEventListener('pointermove', move) + document.documentElement.removeEventListener('pointerleave', reset) + motionAllowed.removeEventListener('change', reset) + } + }, [layers]) + + return ( +
+ {layers.map((layer, index) => ( +
{ + parallaxRefs.current[index] = element + }} + className={`absolute inset-0 ${index >= 3 ? 'opacity-100' : 'opacity-60 sm:opacity-70 xl:opacity-90'} ${index !== 2 ? 'pointer-events-none' : ''}`} + style={{ + transform: 'perspective(1400px) scale(1.035)', + willChange: 'transform', + }} + > + +
+ ))} +
+ ) +} diff --git a/src/components/landing/ChartsLanding.tsx b/src/components/landing/ChartsLanding.tsx index 31969057b..20a877f63 100644 --- a/src/components/landing/ChartsLanding.tsx +++ b/src/components/landing/ChartsLanding.tsx @@ -12,7 +12,8 @@ import { BundleSizeChart, ThemeGallery, } from './ChartsLandingGraphics' -import { CatalogChartsHero, ChartsCatalogGallery } from './ChartsCatalogGallery' +import { ChartsCatalogGallery } from './ChartsCatalogGallery' +import { ChartsHeroBackground } from './ChartsHeroBackground' const chartPrompt = `Using TanStack Charts and the accounts array in this project, plot monthlyRevenue on x and retention on y. Size each point by seats with an explicit square-root radius scale, color it by segment, and preserve the original Account rows for typed tooltip and focus callbacks. Use the compact linear scale from @tanstack/charts/scales/linear so TanStack Charts can infer the domains, add the tooltip behavior from @tanstack/charts/tooltip, and render it through the React adapter with a useful ariaLabel.` @@ -27,7 +28,7 @@ export default function ChartsLanding({ } + heroBackground={} libraryId="charts" prompt={chartPrompt} promptLabel="Copy Charts prompt" diff --git a/src/components/landing/ChartsTextBackdrop.tsx b/src/components/landing/ChartsTextBackdrop.tsx new file mode 100644 index 000000000..bc0b8a044 --- /dev/null +++ b/src/components/landing/ChartsTextBackdrop.tsx @@ -0,0 +1,124 @@ +import * as React from 'react' + +export function ChartsTextBackdrop({ + children, +}: { + children: React.ReactNode +}) { + const contentRef = React.useRef(null) + const [mask, setMask] = React.useState('') + + React.useEffect(() => { + const content = contentRef.current + if (!content) return + let active = true + let frame = 0 + let revision = 0 + const images = Array.from(content.querySelectorAll('img')) + + const updateMask = () => { + if (!active) return + const currentRevision = ++revision + const bounds = content.getBoundingClientRect() + const padding = 192 + const canvas = document.createElement('canvas') + canvas.width = Math.ceil((bounds.width + padding * 2) / 2) + canvas.height = Math.ceil((bounds.height + padding * 2) / 2) + const context = canvas.getContext('2d') + if (!context) return + context.scale(0.5, 0.5) + context.fillStyle = 'white' + context.strokeStyle = 'white' + context.lineWidth = 112 + context.lineJoin = 'round' + context.textBaseline = 'top' + + const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT) + const range = document.createRange() + while (walker.nextNode()) { + const node = walker.currentNode + const parent = node.parentElement + if (!parent || !node.textContent?.trim()) continue + const style = getComputedStyle(parent) + context.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}` + for (let index = 0; index < node.textContent.length; index++) { + const character = node.textContent[index] + if (!character.trim()) continue + range.setStart(node, index) + range.setEnd(node, index + 1) + const rect = range.getBoundingClientRect() + if (!rect.width || !rect.height) continue + const x = rect.left - bounds.left + padding + const y = rect.top - bounds.top + padding + context.strokeText(character, x, y) + context.fillText(character, x, y) + } + } + for (const image of images) { + const rect = image.getBoundingClientRect() + if (!rect.width || !image.complete || !image.naturalWidth) continue + context.drawImage( + image, + rect.left - bounds.left + padding, + rect.top - bounds.top + padding, + rect.width, + rect.height, + ) + } + + const soft = document.createElement('canvas') + soft.width = canvas.width + soft.height = canvas.height + const softContext = soft.getContext('2d') + if (!softContext) return + softContext.filter = 'blur(28px)' + for (let pass = 0; pass < 4; pass++) softContext.drawImage(canvas, 0, 0) + soft.toBlob((blob) => { + if (!blob || !active || revision !== currentRevision) return + setMask(URL.createObjectURL(blob)) + }) + } + + const scheduleMask = () => { + if (!active) return + cancelAnimationFrame(frame) + frame = requestAnimationFrame(updateMask) + } + const observer = new ResizeObserver(scheduleMask) + observer.observe(content) + for (const image of images) image.addEventListener('load', scheduleMask) + void document.fonts.ready.then(scheduleMask) + return () => { + active = false + cancelAnimationFrame(frame) + observer.disconnect() + for (const image of images) + image.removeEventListener('load', scheduleMask) + } + }, []) + + React.useEffect( + () => () => { + if (mask) URL.revokeObjectURL(mask) + }, + [mask], + ) + + return ( +
+ + ) +} diff --git a/src/components/landing/LibraryLanding.tsx b/src/components/landing/LibraryLanding.tsx index b2d8f52fb..833403c19 100644 --- a/src/components/landing/LibraryLanding.tsx +++ b/src/components/landing/LibraryLanding.tsx @@ -17,6 +17,7 @@ import { PartnersSponsorsSection } from '~/components/PartnersSponsorsSection' import { LibraryStatusBadge } from '~/components/LibraryStatusBadge' import { ossStatsQuery, recentDownloadsQuery } from '~/queries/stats' import { LandingCopyPromptButton } from './LandingCopyPromptButton' +import { ChartsTextBackdrop } from './ChartsTextBackdrop' export type LibraryLandingId = Exclude< LibraryId, @@ -82,7 +83,8 @@ export type LibraryLandingShellProps = { children: React.ReactNode description: React.ReactNode headline: string - hero: React.ReactNode + hero?: React.ReactNode + heroBackground?: React.ReactNode beforeActions?: React.ReactNode libraryId: LibraryLandingId prompt?: string @@ -213,12 +215,15 @@ export function LibraryLandingShell({ description, headline, hero, + heroBackground, beforeActions, libraryId, prompt, promptLabel, stackHero = false, }: LibraryLandingShellProps) { + const CopyBackdrop = heroBackground ? ChartsTextBackdrop : React.Fragment + const haloId = React.useId() const library = getLibrary(libraryId) const { version } = useParams({ strict: false }) const resolvedVersion = version ?? library.latestVersion @@ -250,7 +255,11 @@ export function LibraryLandingShell({ }} /> -
+ {heroBackground} + +
-
-
-
- TanStack - + {heroBackground ? ( +

- - {library.name.replace(/^TanStack\s+/i, '')} - -

+ + ) : null} + +
+
+
+ TanStack + +

+ + {library.name.replace(/^TanStack\s+/i, '')} + +

+
+ {library.badge ? ( + + ) : null} +
+ +

+ {headline} +

+

+ {description} +

- {library.badge ? ( - - ) : null} -
- -

- {headline} -

-

- {description} -

+ {beforeActions}
{prompt ? ( diff --git a/tests/charts-catalog-source-previews.test.ts b/tests/charts-catalog-source-previews.test.ts index b7f8f0c65..68dc4c9fd 100644 --- a/tests/charts-catalog-source-previews.test.ts +++ b/tests/charts-catalog-source-previews.test.ts @@ -13,10 +13,8 @@ import { } from '@tanstack/react-router' import { ChartsCatalogPreview } from '../src/components/charts/ChartsCatalogPreview' -import { - CatalogChartsHero, - ChartsCatalogGallery, -} from '../src/components/landing/ChartsCatalogGallery' +import { ChartsCatalogGallery } from '../src/components/landing/ChartsCatalogGallery' +import { ChartsHeroBackground } from '../src/components/landing/ChartsHeroBackground' import { serveChartsCatalogPreview } from '../src/routes/charts.catalog_.previews.$revision.{$caseId}[.]svg' import { getChartsCatalogPreviewHeaders, @@ -288,7 +286,7 @@ test('landing and catalog cards use the landing publication revision', () => { RouterContextProvider({ router, children: createElement('div', null, [ - createElement(CatalogChartsHero, { catalog, key: 'hero' }), + createElement(ChartsHeroBackground, { key: 'hero' }), createElement(ChartsCatalogGallery, { catalog, key: 'gallery', diff --git a/tests/charts-landing-ssr.test.ts b/tests/charts-landing-ssr.test.ts index 2342fcc16..1395691e5 100644 --- a/tests/charts-landing-ssr.test.ts +++ b/tests/charts-landing-ssr.test.ts @@ -12,11 +12,8 @@ import { createRouter, } from '@tanstack/react-router' -import { - CatalogChartsHero, - ChartsCatalogGallery, - chartsLandingHeroCaseIds, -} from '../src/components/landing/ChartsCatalogGallery' +import { ChartsCatalogGallery } from '../src/components/landing/ChartsCatalogGallery' +import { ChartsHeroBackground } from '../src/components/landing/ChartsHeroBackground' import { shuffleWithSeed } from '../src/utils/utils' const galleryOrderSeed = 'charts-landing-ssr' @@ -47,10 +44,10 @@ const catalog = { title: 'Labeled ordinal heatmap', }, { - id: '70-composed-chart', - family: 'composition', + id: '73-many-point-scatter', + family: 'performance', order: 4, - title: 'Seattle weather with three y axes', + title: 'Automobile specifications scatter', }, { id: '84-pinned-nested-chart-tooltip', @@ -103,18 +100,6 @@ const catalog = { ], } -test('the landing hero uses a fixed set of distinct catalog cases', () => { - assert.equal( - new Set(chartsLandingHeroCaseIds).size, - chartsLandingHeroCaseIds.length, - ) - assert.deepEqual(chartsLandingHeroCaseIds, [ - '70-composed-chart', - '101-sunburst', - '127-shadcn-dashboard', - ]) -}) - test('the landing server-renders revision-pinned chart preview assets', () => { const shuffledCases = shuffleWithSeed( [...catalog.cases].sort((left, right) => left.order - right.order), @@ -135,7 +120,7 @@ test('the landing server-renders revision-pinned chart preview assets', () => { RouterContextProvider({ router, children: createElement(Fragment, null, [ - createElement(CatalogChartsHero, { catalog, key: 'hero' }), + createElement(ChartsHeroBackground, { key: 'hero' }), createElement(ChartsCatalogGallery, { catalog, key: 'gallery', @@ -153,8 +138,14 @@ test('the landing server-renders revision-pinned chart preview assets', () => { }).byteLength const elements = $('*').length - assert.ok(rawBytes <= landingCatalogSsrBudget.rawBytes) - assert.ok(brotliQuality4Bytes <= landingCatalogSsrBudget.brotliQuality4Bytes) + assert.ok( + rawBytes <= landingCatalogSsrBudget.rawBytes, + `${rawBytes} raw bytes`, + ) + assert.ok( + brotliQuality4Bytes <= landingCatalogSsrBudget.brotliQuality4Bytes, + `${brotliQuality4Bytes} compressed bytes`, + ) assert.ok(elements <= landingCatalogSsrBudget.elements) const shuffledIds = shuffledCases.map((catalogCase) => catalogCase.id) @@ -175,28 +166,23 @@ test('the landing server-renders revision-pinned chart preview assets', () => { shuffledIds, ) - const hero = $('section[aria-label="Chart catalog examples"]') - const heroPreviews = hero.find('img[data-catalog-preview-case]') - assert.deepEqual( - heroPreviews - .map((_, element) => $(element).attr('data-catalog-preview-case')) - .get(), - [...chartsLandingHeroCaseIds], - ) - assert.equal(hero.children('.grid').hasClass('grid-cols-2'), true) - assert.equal(hero.find('figure').first().hasClass('col-span-2'), true) + const hero = $('.charts-hero-background') + assert.equal(hero.find('svg, .ts-chart-host').length, 0) + assert.equal(hero.find('img, text').length, 0) + assert.equal(hero.attr('aria-hidden'), 'true') + assert.equal(hero.find('figure, figcaption, a').length, 0) assert.equal( $('img[src^="/charts/catalog/previews/"]').length, - catalog.cases.length + chartsLandingHeroCaseIds.length, + catalog.cases.length, ) assert.equal( $('img[data-catalog-preview-case][loading="lazy"]').length, - catalog.cases.length + chartsLandingHeroCaseIds.length, + catalog.cases.length, ) assert.equal( $('img[data-catalog-preview-case][decoding="async"]').length, - catalog.cases.length + chartsLandingHeroCaseIds.length, + catalog.cases.length, ) assert.equal($('.charts-catalog-chart').length, 0) assert.equal($('[data-chart-case]').length, 0)