diff --git a/.eslintrc.js b/.eslintrc.js index 6b459fd61b..0b72d4b386 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -74,7 +74,7 @@ module.exports = { 'import/no-unresolved': [ 'error', { - ignore: ['monaco-editor', 'vscode'] + ignore: ['monaco-editor', 'vscode', 'react-error-boundary'] } ], 'import/prefer-default-export': 'off', diff --git a/package-lock.json b/package-lock.json index f78241cc48..15c748e392 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "react": "^16.5.2", "react-data-grid": "^6.0.2-0", "react-dom": "^16.5.2", + "react-error-boundary": "^6.0.0", "react-redux": "^7.1.1", "react-svg-pan-zoom": "3.9.0", "react-svgmt": "1.1.11", @@ -16525,6 +16526,18 @@ "react": "^16.14.0" } }, + "node_modules/react-error-boundary": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz", + "integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -32556,6 +32569,14 @@ "scheduler": "^0.19.1" } }, + "react-error-boundary": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz", + "integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==", + "requires": { + "@babel/runtime": "^7.12.5" + } + }, "react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", diff --git a/package.json b/package.json index 575cc95d56..d9bffeb461 100644 --- a/package.json +++ b/package.json @@ -2197,6 +2197,7 @@ "react": "^16.5.2", "react-data-grid": "^6.0.2-0", "react-dom": "^16.5.2", + "react-error-boundary": "^6.0.0", "react-redux": "^7.1.1", "react-svg-pan-zoom": "3.9.0", "react-svgmt": "1.1.11", diff --git a/src/webviews/webview-side/vega-renderer/ErrorBoundary.tsx b/src/webviews/webview-side/vega-renderer/ErrorBoundary.tsx new file mode 100644 index 0000000000..8ea2169b1a --- /dev/null +++ b/src/webviews/webview-side/vega-renderer/ErrorBoundary.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import type { FallbackProps } from 'react-error-boundary'; + +export function ErrorFallback({ error }: FallbackProps) { + return ( +
+
Error rendering chart
+
{error.message}
+
+ Stack trace +
+                    {error.stack}
+                
+
+
+ ); +} diff --git a/src/webviews/webview-side/vega-renderer/VegaRenderer.tsx b/src/webviews/webview-side/vega-renderer/VegaRenderer.tsx index 8077285108..e52eac88fb 100644 --- a/src/webviews/webview-side/vega-renderer/VegaRenderer.tsx +++ b/src/webviews/webview-side/vega-renderer/VegaRenderer.tsx @@ -1,15 +1,53 @@ import { chartColors10, chartColors20, deepnoteBlues } from './colors'; -import React, { memo, useLayoutEffect } from 'react'; +import React, { memo, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { Vega } from 'react-vega'; import { vega } from 'vega-embed'; +import { produce } from 'immer'; import { numberFormats } from './number-formats'; +import { detectBaseTheme } from '../react-common/themeDetector'; +import type { Spec as VegaSpec } from 'vega'; export interface VegaRendererProps { - spec: Record; + spec: VegaSpec; renderer?: 'svg' | 'canvas'; } +interface ThemeColors { + backgroundColor: string; + foregroundColor: string; + isDark: boolean; +} + +const getThemeColors = (): ThemeColors => { + const theme = detectBaseTheme(); + const isDark = theme === 'vscode-dark' || theme === 'vscode-high-contrast'; + const styles = getComputedStyle(document.body); + const backgroundColor = styles.getPropertyValue('--vscode-editor-background').trim() || 'transparent'; + const foregroundColor = styles.getPropertyValue('--vscode-editor-foreground').trim() || '#000000'; + + return { backgroundColor, foregroundColor, isDark }; +}; + +function useThemeColors(): ThemeColors { + const [themeColors, setThemeColors] = useState(getThemeColors); + + useEffect(() => { + const observer = new MutationObserver(() => { + setThemeColors(getThemeColors()); + }); + + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class', 'data-vscode-theme-name'] + }); + + return () => observer.disconnect(); + }, []); + + return themeColors; +} + export const VegaRenderer = memo(function VegaRenderer(props: VegaRendererProps) { const { renderer, spec } = props; @@ -31,9 +69,58 @@ export const VegaRenderer = memo(function VegaRenderer(props: VegaRendererProps) vega.scheme('deepnote_blues', deepnoteBlues); }, []); + const { backgroundColor, foregroundColor, isDark } = useThemeColors(); + const themedSpec = useMemo(() => { + const patchedSpec = produce(spec, (draft) => { + draft.background = backgroundColor; + + if (!draft.config) { + draft.config = {}; + } + + draft.config.background = backgroundColor; + + if (!draft.config.style) { + draft.config.style = {}; + } + if (!draft.config.style.cell) { + draft.config.style.cell = {}; + } + draft.config.style.cell = { + stroke: backgroundColor + }; + + if (!draft.config.axis) { + draft.config.axis = {}; + } + draft.config.axis.domainColor = foregroundColor; + draft.config.axis.gridColor = isDark ? '#3e3e3e' : '#e0e0e0'; + draft.config.axis.tickColor = foregroundColor; + draft.config.axis.labelColor = foregroundColor; + draft.config.axis.titleColor = foregroundColor; + + if (!draft.config.legend) { + draft.config.legend = {}; + } + draft.config.legend.labelColor = foregroundColor; + draft.config.legend.titleColor = foregroundColor; + + if (!draft.config.title) { + draft.config.title = {}; + } + draft.config.title.color = foregroundColor; + + if (!draft.config.text) { + draft.config.text = {}; + } + draft.config.text.fill = foregroundColor; + }); + return structuredClone(patchedSpec); // Immer freezes the spec, which doesn't play well with Vega + }, [spec, backgroundColor, foregroundColor, isDark]); + return ( ) const elementsCache: Record = {}; return { renderOutputItem(outputItem: OutputItem, element: HTMLElement) { - console.log(`Vega renderer - rendering output item: ${outputItem.id}`); try { const spec = outputItem.json(); - - console.log(`Vega renderer - received spec with ${Object.keys(spec).length} keys`); - - const metadata = outputItem.metadata as Metadata | undefined; - - console.log('[VegaRenderer] Full metadata', metadata); - const root = document.createElement('div'); root.style.height = '500px'; element.appendChild(root); elementsCache[outputItem.id] = root; ReactDOM.render( - React.createElement(VegaRenderer, { - spec: spec - }), + React.createElement( + ErrorBoundary, + { + FallbackComponent: ErrorFallback, + onError: (error, info) => { + console.error('Vega renderer error:', error, info); + } + }, + React.createElement(VegaRenderer, { + spec: spec + }) + ), root ); } catch (error) {