diff --git a/src/components/charts/ChartsCatalogChart.tsx b/src/components/charts/ChartsCatalogChart.tsx new file mode 100644 index 000000000..94d5230c6 --- /dev/null +++ b/src/components/charts/ChartsCatalogChart.tsx @@ -0,0 +1,208 @@ +import * as React from 'react' +import { getChartsCatalogAssetUrl } from '~/utils/charts-catalog' + +export type ChartsCatalogModuleReference = { + path: string + preload: Array +} + +type ChartMountInput = { + width: number + height: number + revision: number + interactive?: boolean +} + +type ChartMountHandle = { + update(input: ChartMountInput): void + destroy(): void +} + +type ChartRuntimeModule = { + mount(container: HTMLElement, input: ChartMountInput): ChartMountHandle +} + +export function ChartsCatalogChart({ + artifactRevision, + caseId, + defer = false, + height = 360, + interactive = true, + module, + onStatus, + revision = 0, +}: { + artifactRevision: string + caseId: string + defer?: boolean + height?: number + interactive?: boolean + module: ChartsCatalogModuleReference + onStatus?: (status: 'ready' | 'resize' | 'error') => void + revision?: number +}) { + const containerRef = React.useRef(null) + const handleRef = React.useRef(undefined) + const inputRef = React.useRef({ height, interactive, revision }) + const onStatusRef = React.useRef(onStatus) + const [visible, setVisible] = React.useState(!defer) + const [failed, setFailed] = React.useState(false) + + React.useEffect(() => { + inputRef.current = { height, interactive, revision } + }, [height, interactive, revision]) + + React.useEffect(() => { + onStatusRef.current = onStatus + }, [onStatus]) + + React.useEffect(() => { + const container = containerRef.current + if (!defer || visible || !container) return + + if (!('IntersectionObserver' in window)) { + setVisible(true) + return + } + + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + setVisible(true) + observer.disconnect() + }, + { rootMargin: '480px 0px' }, + ) + observer.observe(container) + return () => observer.disconnect() + }, [defer, visible]) + + React.useEffect(() => { + const container = containerRef.current + if (!container || !visible) return + + let cancelled = false + let mountedHandle: ChartMountHandle | undefined + let width = measureWidth(container) + const preloadLinks = module.preload.map((assetPath) => { + const link = document.createElement('link') + link.rel = 'modulepreload' + link.href = getChartsCatalogAssetUrl(artifactRevision, assetPath) + document.head.appendChild(link) + return link + }) + + setFailed(false) + + void import( + /* @vite-ignore */ + getChartsCatalogAssetUrl(artifactRevision, module.path) + ) + .then((loaded: unknown) => { + if (cancelled) return + if (!isChartRuntimeModule(loaded)) { + throw new TypeError('Invalid Charts catalog runtime module') + } + + const mounted = loaded.mount(container, { + width, + ...inputRef.current, + }) + if (!isChartMountHandle(mounted)) { + throw new TypeError('Invalid Charts catalog mount handle') + } + mountedHandle = mounted + handleRef.current = mounted + requestAnimationFrame(() => { + if (!cancelled) onStatusRef.current?.('ready') + }) + }) + .catch((error: unknown) => { + if (cancelled) return + console.error(`Unable to mount Charts catalog case ${caseId}`, error) + setFailed(true) + onStatusRef.current?.('error') + }) + + const resizeObserver = new ResizeObserver(() => { + const nextWidth = measureWidth(container) + if (nextWidth === width || nextWidth < 1) return + width = nextWidth + handleRef.current?.update({ + width, + ...inputRef.current, + }) + onStatusRef.current?.('resize') + }) + resizeObserver.observe(container) + + return () => { + cancelled = true + resizeObserver.disconnect() + mountedHandle?.destroy() + if (handleRef.current === mountedHandle) { + handleRef.current = undefined + } + for (const link of preloadLinks) link.remove() + container.replaceChildren() + } + }, [artifactRevision, caseId, module, visible]) + + React.useEffect(() => { + const container = containerRef.current + const handle = handleRef.current + if (!container || !handle) return + + handle.update({ + width: measureWidth(container), + height, + interactive, + revision, + }) + }, [height, interactive, revision]) + + return ( +
+
+ {!visible || failed ? ( +
+ {failed ? 'Render failed' : null} +
+ ) : null} +
+ ) +} + +function measureWidth(container: HTMLElement) { + return Math.max(1, Math.floor(container.getBoundingClientRect().width)) +} + +function isChartRuntimeModule(value: unknown): value is ChartRuntimeModule { + return ( + typeof value === 'object' && + value !== null && + 'mount' in value && + typeof value.mount === 'function' + ) +} + +function isChartMountHandle(value: unknown): value is ChartMountHandle { + return ( + typeof value === 'object' && + value !== null && + 'update' in value && + typeof value.update === 'function' && + 'destroy' in value && + typeof value.destroy === 'function' + ) +} diff --git a/src/components/charts/ChartsCatalogEmbed.tsx b/src/components/charts/ChartsCatalogEmbed.tsx new file mode 100644 index 000000000..68f215e7f --- /dev/null +++ b/src/components/charts/ChartsCatalogEmbed.tsx @@ -0,0 +1,121 @@ +import * as React from 'react' +import { useTheme } from '~/components/ThemeProvider' +import { parseChartsCatalogEmbed } from '~/utils/charts-catalog-embed' + +type ChartsCatalogEmbedProps = Omit< + React.IframeHTMLAttributes, + 'src' +> & { + deferUntilVisible?: boolean + src: string + theme?: 'dark' | 'light' | 'system' +} + +export function ChartsCatalogEmbed({ + className, + deferUntilVisible = false, + loading = 'lazy', + onLoad, + src, + theme = 'system', + title, + ...iframeProps +}: ChartsCatalogEmbedProps) { + const { resolvedTheme } = useTheme() + const frameRef = React.useRef(null) + const [shouldLoad, setShouldLoad] = React.useState(!deferUntilVisible) + const chartEmbed = React.useMemo(() => parseChartsCatalogEmbed(src), [src]) + const iframeTitle = title?.trim() || 'TanStack Charts example' + const resolvedEmbedTheme = theme === 'system' ? resolvedTheme : theme + + React.useEffect(() => { + const frame = frameRef.current + if (!deferUntilVisible || shouldLoad || !frame) return + + if (!('IntersectionObserver' in window)) { + setShouldLoad(true) + return + } + + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + setShouldLoad(true) + observer.disconnect() + }, + { rootMargin: '480px 0px' }, + ) + observer.observe(frame) + return () => observer.disconnect() + }, [deferUntilVisible, shouldLoad]) + + const postChartTheme = React.useCallback(() => { + const target = frameRef.current?.contentWindow + if (!chartEmbed || !target || !shouldLoad) return + + target.postMessage( + { + type: 'tanstack-charts:embed', + version: 1, + command: 'set-theme', + caseId: chartEmbed.caseId, + theme: resolvedEmbedTheme, + }, + chartEmbed.origin, + ) + }, [chartEmbed, resolvedEmbedTheme, shouldLoad]) + + React.useEffect(() => { + const target = frameRef.current?.contentWindow + if (!chartEmbed || !target || !shouldLoad) return + + const handleMessage = (event: MessageEvent) => { + if ( + event.origin !== chartEmbed.origin || + event.source !== target || + !isReadyChartEmbedMessage(event.data, chartEmbed.caseId) + ) { + return + } + postChartTheme() + } + + window.addEventListener('message', handleMessage) + postChartTheme() + return () => window.removeEventListener('message', handleMessage) + }, [chartEmbed, postChartTheme, shouldLoad]) + + if (!chartEmbed) return null + + return ( +