diff --git a/__mocks__/rehype-raw.js b/__mocks__/rehype-raw.js new file mode 100644 index 0000000..74a2d3a --- /dev/null +++ b/__mocks__/rehype-raw.js @@ -0,0 +1,16 @@ +/** + * Mock for rehype-raw plugin + * + * rehype-raw is an ES module that causes issues with Jest's CommonJS environment. + * This mock provides a no-op transformer that passes the syntax tree through unchanged. + * + * In tests, we don't need actual HTML parsing since we're testing component behavior, + * not the markdown rendering itself. + */ +module.exports = function rehypeRaw() { + return function transformer(tree) { + return tree; + }; +}; + +module.exports.default = module.exports; diff --git a/__mocks__/remark-gfm.js b/__mocks__/remark-gfm.js new file mode 100644 index 0000000..a2acd55 --- /dev/null +++ b/__mocks__/remark-gfm.js @@ -0,0 +1,16 @@ +/** + * Mock for remark-gfm (GitHub Flavored Markdown) plugin + * + * remark-gfm is an ES module that causes issues with Jest's CommonJS environment. + * This mock provides a no-op transformer that passes the syntax tree through unchanged. + * + * In tests, we don't need actual GFM parsing (tables, strikethrough, etc.) since + * we're testing component behavior, not markdown rendering. + */ +module.exports = function remarkGfm() { + return function transformer(tree) { + return tree; + }; +}; + +module.exports.default = module.exports; diff --git a/__mocks__/remark-math.js b/__mocks__/remark-math.js new file mode 100644 index 0000000..60b4f87 --- /dev/null +++ b/__mocks__/remark-math.js @@ -0,0 +1,16 @@ +/** + * Mock for remark-math plugin + * + * remark-math is an ES module that causes issues with Jest's CommonJS environment. + * This mock provides a no-op transformer that passes the syntax tree through unchanged. + * + * In tests, we don't need actual LaTeX math parsing since we're testing component + * behavior, not mathematical equation rendering. + */ +module.exports = function remarkMath() { + return function transformer(tree) { + return tree; + }; +}; + +module.exports.default = module.exports; diff --git a/__tests__/components/Chat/ChatMessage.test.tsx b/__tests__/components/Chat/ChatMessage.test.tsx new file mode 100644 index 0000000..27c76c3 --- /dev/null +++ b/__tests__/components/Chat/ChatMessage.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ChatMessage } from '@/components/Chat/ChatMessage'; +import HomeContext from '@/pages/api/home/home.context'; +import { Message } from '@/types/chat'; + +// Mock the HtmlFileRenderer to check if it's being rendered +jest.mock('@/components/Chat/HtmlFileRenderer', () => ({ + HtmlFileRenderer: jest.fn(() =>
), +})); + +// Mock the Avatar component +jest.mock('@/components/Avatar/BotAvatar', () => ({ + BotAvatar: jest.fn(() =>
), +})); + +describe('ChatMessage', () => { + const mockHomeContextValue = { + state: { + selectedConversation: { + messages: [], + }, + conversations: [], + messageIsStreaming: false, + }, + dispatch: jest.fn(), + }; + + const renderWithMessage = (message: Message) => { + return render( + + + + ); + }; + + it('should render HtmlFileRenderer for an assistant message with an HTML file link', () => { + const message: Message = { + id: "1", + role: 'assistant', + content: 'Here is your plot: Plot', + }; + renderWithMessage(message); + + expect(screen.getByTestId('html-file-renderer')).toBeInTheDocument(); + }); + + it('should remove the HTML file link from the displayed message content', () => { + const message: Message = { + id: "1", + role: 'assistant', + content: 'Here is your plot: Plot', + }; + renderWithMessage(message); + + // Verify the link is removed but surrounding text is preserved + expect(screen.getByText(/Here is your plot/i)).toBeInTheDocument(); + // The HtmlFileRenderer should be shown instead of the raw link + expect(screen.getByTestId('html-file-renderer')).toBeInTheDocument(); + }); + + it('should not render HtmlFileRenderer for a user message, even with a link', () => { + const message: Message = { + id: "1", + role: 'user', + content: 'Can you show me a plot from Plot?', + }; + renderWithMessage(message); + + expect(screen.queryByTestId('html-file-renderer')).not.toBeInTheDocument(); + // Check that the content is rendered as is for the user message + expect(screen.getByText(/Can you show me a plot from/)).toBeInTheDocument(); + }); + + it('should not render HtmlFileRenderer for an assistant message without a link', () => { + const message: Message = { + id: "1", + role: 'assistant', + content: 'Hello! How can I help you today?', + }; + renderWithMessage(message); + + expect(screen.queryByTestId('html-file-renderer')).not.toBeInTheDocument(); + expect(screen.getByText('Hello! How can I help you today?')).toBeInTheDocument(); + }); + + it('should render HtmlFileRenderer for inline HTML content', () => { + const message: Message = { + id: "1", + role: 'assistant', + content: 'Here is an inline plot:

My Plot

', + }; + renderWithMessage(message); + + expect(screen.getByTestId('html-file-renderer')).toBeInTheDocument(); + expect(screen.getByText(/Here is an inline plot/i)).toBeInTheDocument(); + }); + + it('should handle multiple HTML files in one message', () => { + const message: Message = { + id: "1", + role: 'assistant', + content: 'First: Plot 1 and second: [Plot 2](file:///plot2.html)', + }; + renderWithMessage(message); + + // Should render multiple HtmlFileRenderer components (mocked) + const renderers = screen.getAllByTestId('html-file-renderer'); + expect(renderers.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/__tests__/components/Chat/HtmlFileRenderer.test.tsx b/__tests__/components/Chat/HtmlFileRenderer.test.tsx new file mode 100644 index 0000000..3fc9e0f --- /dev/null +++ b/__tests__/components/Chat/HtmlFileRenderer.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { HtmlFileRenderer } from '@/components/Chat/HtmlFileRenderer'; + +// Mock fetch +global.fetch = jest.fn(); + +// Mock navigator.clipboard +Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: jest.fn().mockResolvedValue(undefined), + }, + writable: true, +}); + +describe('HtmlFileRenderer', () => { + beforeEach(() => { + (fetch as jest.Mock).mockClear(); + (navigator.clipboard.writeText as jest.Mock).mockClear(); + }); + + it('should render loading state initially, then render the plot when file exists', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, // For checkFileExists + }); + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve('

Test Plot

'), // For loadHtmlContent + }); + + render(); + + // It should initially show a loading skeleton, but that's hard to test without more specific selectors. + // We'll wait for the content to appear. + + await waitFor(() => { + expect(screen.getByTitle('My Plot')).toBeInTheDocument(); + }); + + const iframe = screen.getByTitle('My Plot'); + expect(iframe).toHaveAttribute('srcDoc', '

Test Plot

'); + }); + + it('should render nothing if the file does not exist', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 404, + }); + + const { container } = render(); + + await waitFor(() => { + expect(container.firstChild).toBeNull(); + }); + }); + + it('should display an error message if fetching content fails', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ ok: true }); // file exists check + (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); // content fetch fails + + render(); + + await waitFor(() => { + expect(screen.getByText(/Could not load plot inline/i)).toBeInTheDocument(); + expect(screen.getByText(/Failed to load HTML file: Network error/i)).toBeInTheDocument(); + }); + }); + + it('should render inline HTML content directly without fetching', async () => { + const inlineContent = '

Inline Content

'; + render(); + + await waitFor(() => { + expect(screen.getByTitle('Inline HTML Content')).toBeInTheDocument(); + }); + + const iframe = screen.getByTitle('Inline HTML Content'); + expect(iframe).toHaveAttribute('srcDoc', inlineContent); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should toggle the visibility of the plot when show/hide button is clicked', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + text: () => Promise.resolve(''), + }); + + render(); + + await waitFor(() => { + expect(screen.getByTitle('HTML Content')).toBeInTheDocument(); + }); + + const hideButton = screen.getByRole('button', { name: /Hide Plot/i }); + fireEvent.click(hideButton); + + await waitFor(() => { + expect(screen.queryByTitle('HTML Content')).not.toBeInTheDocument(); + }); + + const showButton = screen.getByRole('button', { name: /Show Plot/i }); + fireEvent.click(showButton); + + await waitFor(() => { + expect(screen.getByTitle('HTML Content')).toBeInTheDocument(); + }); + }); + + it('should copy file path to clipboard', async () => { + (fetch as jest.Mock).mockResolvedValue({ ok: true }); + window.alert = jest.fn(); + + render(); + + await waitFor(() => { + expect(screen.getByTitle('Copy file path')).toBeInTheDocument(); + }); + + const copyButton = screen.getByTitle('Copy file path'); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/path/to/plot.html'); + expect(window.alert).toHaveBeenCalledWith('File path copied to clipboard! Paste it into your browser address bar to view the plot.'); + }); + }); + + it('should copy inline html to clipboard', async () => { + const inlineContent = '

Inline Content

'; + window.alert = jest.fn(); + + render(); + + await waitFor(() => { + expect(screen.getByTitle('Copy HTML content')).toBeInTheDocument(); + }); + + const copyButton = screen.getByTitle('Copy HTML content'); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(inlineContent); + expect(window.alert).toHaveBeenCalledWith('HTML content copied to clipboard!'); + }); + }); +}); diff --git a/__tests__/utils/app/htmlFileDetector.test.ts b/__tests__/utils/app/htmlFileDetector.test.ts new file mode 100644 index 0000000..1b46581 --- /dev/null +++ b/__tests__/utils/app/htmlFileDetector.test.ts @@ -0,0 +1,139 @@ +import { detectHtmlFileLinks, removeHtmlFileLinksFromContent } from '@/utils/app/htmlFileDetector'; + +describe('htmlFileDetector', () => { + describe('detectHtmlFileLinks', () => { + describe('basic detection', () => { + it('should detect an HTML anchor tag link', () => { + const content = 'View Plot'; + const links = detectHtmlFileLinks(content); + expect(links).toHaveLength(1); + expect(links[0].filePath).toBe('file:///path/to/plot.html'); + expect(links[0].linkText).toBe('View Plot'); + expect(links[0].isInlineHtml).toBe(false); + }); + + it('should detect a Markdown link', () => { + const content = 'Here is the [plot](file:///path/to/plot.html)'; + const links = detectHtmlFileLinks(content); + expect(links).toHaveLength(1); + expect(links[0].filePath).toBe('file:///path/to/plot.html'); + expect(links[0].linkText).toBe('plot'); + }); + + it('should detect a direct file:// URL', () => { + const content = 'You can find the plot at file:///path/to/plot.html.'; + const links = detectHtmlFileLinks(content); + expect(links).toHaveLength(1); + expect(links[0].filePath).toBe('file:///path/to/plot.html'); + }); + + it('should detect inline HTML content', () => { + const content = 'My Plot...'; + const links = detectHtmlFileLinks(content); + expect(links).toHaveLength(1); + expect(links[0].isInlineHtml).toBe(true); + expect(links[0].htmlContent).toBe(content); + expect(links[0].title).toBe('My Plot'); + }); + }); + + describe('edge cases', () => { + it('should handle multiple HTML files in one message', () => { + const content = ` + First: file:///plot1.html + Second: /path/to/plot2.html + Third: [plot3](file:///plot3.html) + `; + const links = detectHtmlFileLinks(content); + expect(links.length).toBeGreaterThanOrEqual(3); + expect(links.filter(l => !l.isInlineHtml)).toHaveLength(3); + }); + + it('should remove duplicates when same file referenced multiple times', () => { + const content = ` + View Plot + Another link: [plot](file:///path/to/plot.html) + `; + const links = detectHtmlFileLinks(content); + expect(links).toHaveLength(1); + expect(links[0].filePath).toBe('file:///path/to/plot.html'); + }); + + it('should handle paths with hyphens and underscores', () => { + const content = 'file:///my-awesome_plot-2024.html'; + const links = detectHtmlFileLinks(content); + expect(links).toHaveLength(1); + expect(links[0].filePath).toContain('my-awesome_plot-2024.html'); + }); + + it('should not detect http/https links', () => { + const content = 'Visit site or http://test.com/page.html'; + const links = detectHtmlFileLinks(content); + expect(links).toHaveLength(0); + }); + + it('should return empty array for content with no HTML links', () => { + const content = 'This is a regular message with no plots.'; + const links = detectHtmlFileLinks(content); + expect(links).toHaveLength(0); + }); + }); + }); + + describe('removeHtmlFileLinksFromContent', () => { + it('should remove HTML file links but preserve surrounding text', () => { + const content = 'Please View Plot when you can.'; + const cleaned = removeHtmlFileLinksFromContent(content); + expect(cleaned).toContain('Please'); + expect(cleaned).toContain('when you can'); + expect(cleaned).not.toContain('file://'); + expect(cleaned).not.toContain(' { + const content = 'Here is the [plot](file:///path/to/plot.html) for you.'; + const cleaned = removeHtmlFileLinksFromContent(content); + expect(cleaned).toContain('Here is the'); + expect(cleaned).toContain('for you'); + expect(cleaned).not.toContain('[plot]'); + expect(cleaned).not.toContain('file://'); + }); + + it('should remove standalone file:// URLs', () => { + const content = 'Find it at file:///path/to/plot.html.'; + const cleaned = removeHtmlFileLinksFromContent(content); + expect(cleaned).toMatch(/Find it at\.?/); + expect(cleaned).not.toContain('file://'); + expect(cleaned).not.toContain('.html'); + }); + + it('should remove inline HTML blocks', () => { + const content = 'Here is the plot: .... What do you think?'; + const cleaned = removeHtmlFileLinksFromContent(content); + expect(cleaned).toContain('Here is the plot:'); + expect(cleaned).toContain('What do you think?'); + expect(cleaned).not.toContain(''); + }); + + it('should remove all HTML file references from complex content', () => { + const content = ` + View Plot + [plot](file:///path/to/plot.html) + file:///path/to/plot.html + /path/to/plot.html + Inline + `; + const cleaned = removeHtmlFileLinksFromContent(content); + expect(cleaned).not.toContain('file://'); + expect(cleaned).not.toContain('.html'); + expect(cleaned).not.toContain(''); + expect(cleaned.trim().length).toBeLessThan(10); // Should be mostly empty + }); + + it('should preserve non-HTML content', () => { + const content = 'This is regular text with numbers 123 and symbols @#$'; + const cleaned = removeHtmlFileLinksFromContent(content); + expect(cleaned).toBe(content); + }); + }); +}); diff --git a/components/Chat/ChatMessage.tsx b/components/Chat/ChatMessage.tsx index dbfd8a5..b32a518 100644 --- a/components/Chat/ChatMessage.tsx +++ b/components/Chat/ChatMessage.tsx @@ -33,6 +33,9 @@ import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; +import { HtmlFileRenderer } from './HtmlFileRenderer'; +import { detectHtmlFileLinks, removeHtmlFileLinksFromContent, HtmlFileLink } from '@/utils/app/htmlFileDetector'; + export interface Props { message: Message; messageIndex: number; @@ -182,6 +185,12 @@ export const ChatMessage: FC = memo( }; }, []); + // Detect HTML files in assistant messages + const htmlFileLinks: HtmlFileLink[] = + message.role === 'assistant' + ? detectHtmlFileLinks(message.content) + : []; + const prepareContent = ({ message = {} as Message, responseContent = true, @@ -205,6 +214,32 @@ export const ChatMessage: FC = memo( return fixMalformedHtml(result)?.trim()?.replace(/\n\s+/, '\n '); }; + // Prepare content with HTML file links removed to avoid duplicate display + const prepareContentWithoutHtmlLinks = ({ + message = {} as Message, + responseContent = true, + intermediateStepsContent = false, + role = 'assistant', + } = {}) => { + const { content = '', intermediateSteps = [] } = message; + + if (role === 'user') return content.trim(); + + let result = ''; + if (intermediateStepsContent) { + result += generateContentIntermediate(intermediateSteps); + } + + if (responseContent) { + // Remove HTML file links from content + const cleanContent = removeHtmlFileLinksFromContent(content); + result += result ? `\n\n${cleanContent}` : cleanContent; + } + + // fixing malformed html and removing extra spaces to avoid markdown issues + return fixMalformedHtml(result)?.trim()?.replace(/\n\s+/, '\n '); + }; + return (
= memo( linkTarget="_blank" components={markdownComponents} > - {prepareContent({ + {prepareContentWithoutHtmlLinks({ message, role: 'assistant', intermediateStepsContent: true, @@ -341,7 +376,7 @@ export const ChatMessage: FC = memo( linkTarget="_blank" components={markdownComponents} > - {prepareContent({ + {prepareContentWithoutHtmlLinks({ message, role: 'assistant', intermediateStepsContent: false, @@ -349,6 +384,20 @@ export const ChatMessage: FC = memo( })}
+ {/* HTML File Renderers */} + {htmlFileLinks.length > 0 && ( +
+ {htmlFileLinks.map((htmlFile, index) => ( + + ))} +
+ )}
{!messageIsStreaming && ( <> diff --git a/components/Chat/HtmlFileRenderer.tsx b/components/Chat/HtmlFileRenderer.tsx new file mode 100644 index 0000000..744cf00 --- /dev/null +++ b/components/Chat/HtmlFileRenderer.tsx @@ -0,0 +1,321 @@ +'use client'; +import React, { useState, useEffect } from 'react'; +import { IconEye, IconEyeOff, IconExternalLink, IconFile, IconDownload } from '@tabler/icons-react'; + +interface HtmlFileRendererProps { + filePath: string; + title?: string; + isInlineHtml?: boolean; + htmlContent?: string; +} + +export const HtmlFileRenderer: React.FC = ({ + filePath, + title, + isInlineHtml = false, + htmlContent: inlineHtmlContent +}) => { + const [isExpanded, setIsExpanded] = useState(true); + const [htmlContent, setHtmlContent] = useState(inlineHtmlContent || ''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [fileExists, setFileExists] = useState(null); + + const cleanFilePath = (path: string): string => { + // For inline HTML, return a descriptive name + if (isInlineHtml) { + return title || 'Inline HTML Content'; + } + + // Remove any malformed prefixes or HTML artifacts + let cleaned = path.replace(/^.*?href=["']?/, ''); + cleaned = cleaned.replace(/["'>].*$/, ''); + + // Remove file:// prefix for API call + cleaned = cleaned.replace('file://', ''); + + return cleaned; + }; + + const checkFileExists = async () => { + // For inline HTML, file always "exists" + if (isInlineHtml && inlineHtmlContent) { + setFileExists(true); + return; + } + + try { + const cleanPath = cleanFilePath(filePath); + console.log('Checking if HTML file exists:', cleanPath); + + // Use a simple GET request to check if file exists + // We'll abort quickly to avoid downloading large files + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout + + const response = await fetch(`/api/load-html-file?path=${encodeURIComponent(cleanPath)}`, { + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (response.ok) { + setFileExists(true); + } else if (response.status === 404) { + console.log(`File does not exist: ${cleanPath} (404 Not Found)`); + setFileExists(false); + } else { + console.log(`File check failed: ${cleanPath} (${response.status})`); + setFileExists(false); + } + } catch (err: any) { + // If it's an abort error, still check - might just be slow + if (err.name === 'AbortError') { + console.log(`File check timed out, assuming file exists: ${filePath}`); + setFileExists(true); + } else { + console.log(`Error checking file existence: ${err.message}`); + setFileExists(false); + } + } + }; + + const loadHtmlContent = async () => { + // If it's inline HTML, content is already provided + if (isInlineHtml && inlineHtmlContent) { + setHtmlContent(inlineHtmlContent); + return; + } + + if (isExpanded && !htmlContent && !error && fileExists) { + setIsLoading(true); + setError(''); + + try { + const cleanPath = cleanFilePath(filePath); + console.log('Loading HTML file via API:', cleanPath); + + const response = await fetch(`/api/load-html-file?path=${encodeURIComponent(cleanPath)}`); + + if (!response.ok) { + throw new Error(`Failed to load file: ${response.status} ${response.statusText}`); + } + + const content = await response.text(); + setHtmlContent(content); + } catch (err: any) { + console.error('Error loading HTML file:', err); + setError(`Failed to load HTML file: ${err.message}`); + } finally { + setIsLoading(false); + } + } + }; + + useEffect(() => { + // First check if file exists + checkFileExists(); + }, [filePath, isInlineHtml, inlineHtmlContent]); + + useEffect(() => { + // Only load content if file exists and component is expanded + if (isExpanded && fileExists) { + loadHtmlContent(); + } + }, [isExpanded, fileExists]); + + // Don't render the component if file doesn't exist + if (fileExists === false) { + return null; + } + + // Show loading state while checking file existence + if (fileExists === null) { + return ( +
+
+
+ +
+
+
+
+
+
+
+ ); + } + + const openInSystemBrowser = () => { + if (isInlineHtml) { + // For inline HTML, create a blob URL and try to open it + try { + const blob = new Blob([inlineHtmlContent || ''], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + // Clean up the URL after a delay + setTimeout(() => URL.revokeObjectURL(url), 10000); + } catch (error) { + console.error('Error opening inline HTML:', error); + alert('Unable to open inline HTML content in new window.'); + } + return; + } + + const cleanPath = cleanFilePath(filePath); + // Try to open in system file manager/browser + try { + // For desktop apps or Electron, this might work + if ((window as any).electronAPI) { + (window as any).electronAPI.openFile(cleanPath); + } else { + // Provide instructions to user + alert(`To view this plot, please open the following file in your browser:\n\n${cleanPath}\n\nYou can copy this path and paste it into your browser's address bar.`); + } + } catch (error) { + console.error('Error opening file:', error); + alert(`To view this plot, please open the following file in your browser:\n\n${cleanPath}`); + } + }; + + const copyPathToClipboard = async () => { + try { + if (isInlineHtml) { + // For inline HTML, copy the HTML content itself + await navigator.clipboard.writeText(inlineHtmlContent || ''); + alert('HTML content copied to clipboard!'); + } else { + const cleanPath = cleanFilePath(filePath); + await navigator.clipboard.writeText(cleanPath); + alert('File path copied to clipboard! Paste it into your browser address bar to view the plot.'); + } + } catch (error) { + console.error('Failed to copy to clipboard:', error); + if (isInlineHtml) { + alert('Failed to copy HTML content to clipboard.'); + } else { + const cleanPath = cleanFilePath(filePath); + alert(`Copy this path to your browser:\n\n${cleanPath}`); + } + } + }; + + const displayPath = cleanFilePath(filePath); + + return ( +
+ {/* Header */} +
+
+ +
+ + {title || (isInlineHtml ? 'Inline HTML Content' : 'Interactive Plot')} + +
+ + {isInlineHtml ? 'Inline HTML' : 'HTML Plot'} + + + {isInlineHtml ? 'HTML response content' : 'Interactive visualization'} + +
+
+
+ +
+ + + + + {!isInlineHtml && ( + + )} + + {isInlineHtml && ( + + )} +
+
+ + {/* Content */} + {isExpanded && ( +
+ {isLoading && ( +
+
+ Loading {isInlineHtml ? 'content' : 'plot'}... +
+ )} + + {error && !isInlineHtml && ( +
+

Could not load plot inline

+

{error}

+
+

+ To view this plot: +

+
    +
  1. Click the copy button above to copy the file path
  2. +
  3. Open a new browser tab
  4. +
  5. Paste the path into the address bar
  6. +
  7. Press Enter to view the interactive plot
  8. +
+ +
+
+ )} + + {htmlContent && !error && ( +
+