diff --git a/backend-server/src/services/mistral.ts b/backend-server/src/services/mistral.ts index 1648aa0..acd32bf 100644 --- a/backend-server/src/services/mistral.ts +++ b/backend-server/src/services/mistral.ts @@ -25,7 +25,7 @@ After explaining a concept, you MUST always include these questions: 3. "Did you learn something new?" 4. "Would you like a visual explanation (like a flowchart, diagram, or chart) to see how this works?" -If the user says "Yes" to a visual explanation, generate the appropriate chart, graph, or diagram immediately. +If the user says "Yes" to a visual explanation, generate the appropriate chart, graph, or diagram immediately. If the user directly asks for a visual, always generate one immediately in the required visual format. WEB SEARCH CAPABILITY: You have access to a \`web_search\` tool that searches the live internet via a privacy-respecting meta search engine (SearXNG). Use it whenever the user asks about current events, time-sensitive information, or facts you are not confident about. Return answers with clear, concise explanations and mention relevant URLs from the results.`; diff --git a/components/visuals/ChartRenderer.tsx b/components/visuals/ChartRenderer.tsx index 8d66b36..d5b48dc 100644 --- a/components/visuals/ChartRenderer.tsx +++ b/components/visuals/ChartRenderer.tsx @@ -1,45 +1,58 @@ "use client" -import React, { useRef } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' import { - LineChart, - Line, - BarChart, - Bar, - AreaChart, - Area, - PieChart, - Pie, - RadarChart, - Radar, - PolarGrid, - PolarAngleAxis, - PolarRadiusAxis, - ScatterChart, - Scatter, - ComposedChart, - XAxis, - YAxis, - CartesianGrid, + Chart, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + RadialLinearScale, Tooltip, Legend, - ResponsiveContainer, - Cell, - ZAxis -} from 'recharts' + Filler, + ScatterController, + BubbleController, + LineController, + BarController, + PieController, + RadarController, + type ChartType +} from 'chart.js' + +Chart.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + RadialLinearScale, + Tooltip, + Legend, + Filler, + ScatterController, + BubbleController, + LineController, + BarController, + PieController, + RadarController +) interface ChartData { - type: 'line' | 'bar' | 'area' | 'pie' | 'radar' | 'scatter' | 'composed' + type: 'line' | 'bar' | 'area' | 'pie' | 'radar' | 'scatter' | 'composed' | string title?: string xAxisKey?: string yAxisKey?: string - zAxisKey?: string // For bubble charts or scatter weights + zAxisKey?: string data: any[] series: Array<{ key: string color: string name?: string - type?: 'line' | 'bar' | 'area' | 'scatter' // For composed charts + type?: 'line' | 'bar' | 'area' | 'scatter' data?: any[] }> } @@ -50,327 +63,251 @@ interface ChartRendererProps { const COLORS = ['#00FFA3', '#00B8D9', '#FF5630', '#FFAB00', '#36B37E', '#6554C0', '#FF00E6', '#2979FF'] +const normalizeType = (rawType?: string): ChartType => { + const type = (rawType || '').toLowerCase() + if (type === 'line' || type === 'linechart' || type === 'area' || type === 'areachart' || type === 'composed' || type === 'composedchart' || type === 'combo') return 'line' + if (type === 'bar' || type === 'barchart') return 'bar' + if (type === 'pie' || type === 'piechart') return 'pie' + if (type === 'radar' || type === 'radarchart') return 'radar' + if (type === 'scatter' || type === 'scatterplot') return 'scatter' + return 'bar' +} + export default function ChartRenderer({ config }: ChartRendererProps) { - // Helper to check data availability - const hasRootData = Array.isArray(config?.data) && config.data.length > 0 - const hasSeries = Array.isArray(config?.series) && config.series.length > 0 - const firstSeries = config?.series?.[0] as any - const hasNestedData = hasSeries && Array.isArray(firstSeries?.data) - - // Auto-generate series from data keys if series is missing but data is present - let effectiveSeries = config?.series - let effectiveData = config?.data - - if (hasRootData && !hasSeries) { - // Infer series from data keys (exclude common axis keys) - const axisKeys = ['name', 'label', 'category', 'date', 'year', 'month', 'x', 'axis', config?.xAxisKey].filter(Boolean) as string[] - const firstDataItem = effectiveData?.[0] || {} - const inferredSeriesKeys = Object.keys(firstDataItem).filter(k => { - const val = firstDataItem[k] - const keyLower = k.toLowerCase() - if (axisKeys.includes(keyLower)) return false - // Accept numbers OR numeric strings - return typeof val === 'number' || (typeof val === 'string' && !isNaN(parseFloat(val)) && val.trim() !== '') - }) + const canvasRef = useRef(null) + const chartInstanceRef = useRef(null) + + const processed = useMemo(() => { + const hasRootData = Array.isArray(config?.data) && config.data.length > 0 + const hasSeries = Array.isArray(config?.series) && config.series.length > 0 + const firstSeries = config?.series?.[0] as any + const hasNestedData = hasSeries && Array.isArray(firstSeries?.data) + + let effectiveSeries = config?.series + let effectiveData = config?.data - if (inferredSeriesKeys.length > 0) { - effectiveSeries = inferredSeriesKeys.map((key, i) => ({ - key, - color: COLORS[i % COLORS.length], - name: key.charAt(0).toUpperCase() + key.slice(1) - })) + if (hasRootData && !hasSeries) { + const axisKeys = ['name', 'label', 'category', 'date', 'year', 'month', 'x', 'axis', config?.xAxisKey].filter(Boolean) as string[] + const firstDataItem = effectiveData?.[0] || {} + const inferredSeriesKeys = Object.keys(firstDataItem).filter(k => { + const val = firstDataItem[k] + const keyLower = k.toLowerCase() + if (axisKeys.includes(keyLower)) return false + return typeof val === 'number' || (typeof val === 'string' && !isNaN(parseFloat(val)) && val.trim() !== '') + }) + + if (inferredSeriesKeys.length > 0) { + effectiveSeries = inferredSeriesKeys.map((key, i) => ({ + key, + color: COLORS[i % COLORS.length], + name: key.charAt(0).toUpperCase() + key.slice(1) + })) + } } - } - const hasEffectiveSeries = Array.isArray(effectiveSeries) && effectiveSeries.length > 0 + const hasEffectiveSeries = Array.isArray(effectiveSeries) && effectiveSeries.length > 0 - // Validate config - require either root data or nested series data - if (!config || (!hasRootData && !hasNestedData) || !hasEffectiveSeries) { - return ( -
-

Chart Configuration Error

-

Missing: {!hasRootData && !hasNestedData ? 'data array' : ''} {!hasEffectiveSeries ? 'series (could not infer from data)' : ''}

-
{JSON.stringify(config, null, 2)}
-
- ) - } + if (!config || (!hasRootData && !hasNestedData) || !hasEffectiveSeries) { + return { isValid: false, type: 'bar' as ChartType, xAxisKey: 'name', yAxisKey: undefined as string | undefined, zAxisKey: undefined as string | undefined, title: config?.title, data: [], series: [] } + } + + let { type, xAxisKey = 'name', yAxisKey, zAxisKey, title } = config + let data = effectiveData + const series = effectiveSeries || [] - // Initialize variables with defaults, using the potentially auto-generated series - let { type, xAxisKey = 'name', yAxisKey, zAxisKey, title } = config - let data = effectiveData - let series = effectiveSeries - - // Normalize data: convert string numbers to actual numbers for Recharts - if (Array.isArray(data)) { - data = data.map(item => { - const normalized: any = {} - for (const [key, val] of Object.entries(item)) { - if (typeof val === 'string' && !isNaN(parseFloat(val)) && val.trim() !== '' && key !== xAxisKey) { - normalized[key] = parseFloat(val) - } else { - normalized[key] = val + if (Array.isArray(data)) { + data = data.map(item => { + const normalized: any = {} + for (const [key, val] of Object.entries(item)) { + if (typeof val === 'string' && !isNaN(parseFloat(val)) && val.trim() !== '' && key !== xAxisKey) { + normalized[key] = parseFloat(val) + } else { + normalized[key] = val + } } + return normalized + }) + } + + if (!hasRootData && hasNestedData && firstSeries?.data) { + const dataMap = new Map() + let detectedAxisKey = xAxisKey + + if (xAxisKey === 'name') { + const firstItem = firstSeries.data[0] || {} + const candidate = Object.keys(firstItem).find(k => k !== 'value') + if (candidate) detectedAxisKey = candidate + xAxisKey = detectedAxisKey } - return normalized - }) - } - // Auto-transform nested series data to flat data (Recharts format) if root data is missing - if (!hasRootData && hasNestedData) { - const dataMap = new Map() - let detectedAxisKey = xAxisKey - - // First pass: Detect axis key from first item if default 'name' isn't found - if (xAxisKey === 'name') { - const firstItem = firstSeries.data[0] || {} - // Find a key that is likely the axis (e.g. 'axis', 'category', 'year', 'date') - // or just the first key that isn't 'value' - const candidate = Object.keys(firstItem).find(k => k !== 'value') - if (candidate) detectedAxisKey = candidate - xAxisKey = detectedAxisKey + series.forEach((s: any) => { + if (Array.isArray(s.data)) { + s.data.forEach((item: any) => { + const axisValue = item[detectedAxisKey] + if (axisValue !== undefined) { + if (!dataMap.has(axisValue)) { + dataMap.set(axisValue, { [detectedAxisKey]: axisValue }) + } + let val = item.value !== undefined ? item.value : item + if (typeof val === 'object' && val !== null) { + if (Array.isArray(val)) val = val[1] + else val = val.y ?? val.value ?? val.count ?? val.score ?? val.amount ?? 0 + } + dataMap.get(axisValue)[s.key] = val + } + }) + } + }) + data = Array.from(dataMap.values()) } - series.forEach((s: any) => { - if (Array.isArray(s.data)) { - s.data.forEach((item: any) => { - const axisValue = item[detectedAxisKey] - if (axisValue !== undefined) { - if (!dataMap.has(axisValue)) { - dataMap.set(axisValue, { [detectedAxisKey]: axisValue }) - } - // Use the series key as the value key in the flat object - // If item has 'value', use it. Otherwise assume item is number/value? - let val = item.value !== undefined ? item.value : item - - // Fix for "Objects are not valid as a React child": - // If val is still an object (e.g. {x, y} from scatter data), extract a primitive. - if (typeof val === 'object' && val !== null) { - if (Array.isArray(val)) val = val[1] // Handle [x, y] format - else val = val.y ?? val.value ?? val.count ?? val.score ?? val.amount ?? 0 - } + return { + isValid: true, + type: normalizeType(type), + xAxisKey, + yAxisKey, + zAxisKey, + title, + data: Array.isArray(data) ? data : [], + series + } + }, [config]) - dataMap.get(axisValue)[s.key] = val - } - }) + useEffect(() => { + if (!canvasRef.current) return + + if (chartInstanceRef.current) { + chartInstanceRef.current.destroy() + chartInstanceRef.current = null + } + + if (!processed.isValid || processed.series.length === 0) return + + const isScatter = processed.type === 'scatter' + const isPie = processed.type === 'pie' + const labels = isScatter ? [] : processed.data.map((item: any) => item[processed.xAxisKey] ?? '') + + const datasets = processed.series.map((s: any, i: number) => { + const color = s.color || COLORS[i % COLORS.length] + const isArea = (config?.type || '').toLowerCase().includes('area') || s.type === 'area' + + if (isScatter) { + const scatterData = (Array.isArray(s.data) && s.data.length > 0 ? s.data : processed.data).map((point: any) => ({ + x: Number(point[processed.xAxisKey || 'x']) || 0, + y: Number(point[processed.yAxisKey || 'y']) || Number(point[s.key]) || 0, + ...(processed.zAxisKey ? { r: Number(point[processed.zAxisKey]) || 4 } : {}) + })) + + return { + label: s.name || s.key, + data: scatterData, + borderColor: color, + backgroundColor: `${color}99`, + pointRadius: processed.zAxisKey ? undefined : 5 + } + } + + if (isPie) { + return { + label: s.name || s.key, + data: processed.data.map((item: any) => Number(item[s.key]) || 0), + backgroundColor: processed.data.map((_: any, idx: number) => COLORS[idx % COLORS.length]), + borderColor: '#0A0A0A', + borderWidth: 2 + } + } + + return { + label: s.name || s.key, + data: processed.data.map((item: any) => Number(item[s.key]) || 0), + borderColor: color, + backgroundColor: isArea ? `${color}55` : `${color}CC`, + fill: isArea, + tension: 0.35 } }) - data = Array.from(dataMap.values()) - } - const chartRef = useRef(null) - const handleDownload = () => { - if (!chartRef.current) return - - const svg = chartRef.current.querySelector('svg') - if (!svg) return - - // Create a canvas to draw the SVG - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') - const svgData = new XMLSerializer().serializeToString(svg) - - // Add minimal styling to ensure it looks good on white background if transparent - const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }) - const url = URL.createObjectURL(svgBlob) - - const img = new Image() - img.onload = () => { - canvas.width = svg.clientWidth || 600 - canvas.height = svg.clientHeight || 400 - if (ctx) { - ctx.fillStyle = '#0A0A0A' // Match container bg - ctx.fillRect(0, 0, canvas.width, canvas.height) - ctx.drawImage(img, 0, 0) - - const pngUrl = canvas.toDataURL('image/png') - const downloadLink = document.createElement('a') - downloadLink.href = pngUrl - downloadLink.download = `${title ? title.replace(/\s+/g, '_') : 'tera_chart'}.png` - document.body.appendChild(downloadLink) - downloadLink.click() - document.body.removeChild(downloadLink) - URL.revokeObjectURL(url) + chartInstanceRef.current = new Chart(canvasRef.current, { + type: processed.type, + data: { + labels, + datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + labels: { color: '#d1d5db' } + }, + tooltip: { + backgroundColor: '#111827', + titleColor: '#f9fafb', + bodyColor: '#f9fafb' + } + }, + scales: isPie || isScatter + ? undefined + : { + x: { + ticks: { color: '#9ca3af' }, + grid: { color: '#1f2937' } + }, + y: { + ticks: { color: '#9ca3af' }, + grid: { color: '#1f2937' } + } + } } + }) + + return () => { + chartInstanceRef.current?.destroy() + chartInstanceRef.current = null } - img.src = url + }, [processed, config?.type]) + + const handleDownload = () => { + const chart = chartInstanceRef.current + if (!chart) return + + const downloadLink = document.createElement('a') + downloadLink.href = chart.toBase64Image('image/png', 1) + downloadLink.download = `${(processed.title || 'tera_chart').replace(/\s+/g, '_')}.png` + document.body.appendChild(downloadLink) + downloadLink.click() + document.body.removeChild(downloadLink) } - const normalizedType = (type || '').toLowerCase() - - const renderChart = () => { - switch (normalizedType) { - case 'line': - case 'linechart': - return ( - - - - - - - {series.map((s, i) => ( - - ))} - - ) - case 'bar': - case 'barchart': - return ( - - - - - - - {series.map((s, i) => ( - - ))} - - ) - case 'area': - case 'areachart': - return ( - - - - - - - {series.map((s, i) => ( - - ))} - - ) - case 'radar': - case 'radarchart': - return ( - - - - - - - {series.map((s, i) => ( - - ))} - - ) - case 'scatter': - case 'scatterplot': - return ( - - - - - {zAxisKey && } - - - {series.map((s, i) => ( - - ))} - - ) - case 'composed': - case 'composedchart': - case 'combo': - return ( - - - - - - - {series.map((s, i) => { - const color = s.color || COLORS[i % COLORS.length] - if (s.type === 'bar') return - if (s.type === 'area') return - return - })} - - ) - case 'pie': - case 'piechart': - return ( - - - {data.map((entry, index) => ( - - ))} - - - - - ) - default: - return null - } + if (!processed.isValid) { + return ( +
+

Chart Configuration Error

+

Missing chart data or plottable series.

+
{JSON.stringify(config, null, 2)}
+
+ ) } return (
- {/* Code Section */}

- 📝 Chart Configuration (JSON) + 📝 Chart.js Configuration (JSON)

-                    {JSON.stringify({ type, data, series, xAxisKey, yAxisKey }, null, 2)}
+                    {JSON.stringify({ type: processed.type, data: processed.data, series: processed.series, xAxisKey: processed.xAxisKey, yAxisKey: processed.yAxisKey }, null, 2)}
                 
- {/* Chart Rendering Section */} -
- {title && ( +
+ {processed.title && (

- {title} + {processed.title}

)} -
- - {renderChart() || ( -
- Invalid Chart Config - - Type received: {String(type || 'undefined')} - -
- )} -
+
+
diff --git a/lib/mistral.ts b/lib/mistral.ts index d5f921e..6a43cb2 100644 --- a/lib/mistral.ts +++ b/lib/mistral.ts @@ -27,7 +27,7 @@ After explaining a concept, you MUST always include these questions: 3. "Did you learn something new?" 4. "Would you like a visual explanation (like a flowchart, diagram, or chart) to help you visualize this concept?" -If the user says "Yes" to a visual explanation, generate the appropriate chart, graph, or diagram immediately using the blocks below. +If the user says "Yes" to a visual explanation, generate the appropriate chart, graph, or diagram immediately. If the user directly asks for a visual, always generate one immediately in the required visual format using the blocks below. ABSOLUTE FORMATTING RULE: - NEVER use asterisks (*) for bold or emphasis. Use hyphens (-) for lists. diff --git a/package.json b/package.json index 989df67..fe291f0 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@mistralai/mistralai": "^1.10.0", "@supabase/supabase-js": "^2.84.0", "@vercel/analytics": "^1.5.0", + "chart.js": "^4.5.1", "googleapis": "^167.0.0", "katex": "^0.16.27", "mammoth": "^1.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2397565..00f75b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@vercel/analytics': specifier: ^1.5.0 version: 1.6.1(next@16.0.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) + chart.js: + specifier: ^4.5.1 + version: 4.5.1 googleapis: specifier: ^167.0.0 version: 167.0.0 @@ -487,6 +490,9 @@ packages: peerDependencies: react: ^19.2.3 + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} @@ -1282,6 +1288,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + chevrotain-allstar@0.3.1: resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} peerDependencies: @@ -3400,6 +3410,8 @@ snapshots: transitivePeerDependencies: - zod + '@kurkle/color@0.3.4': {} + '@mermaid-js/parser@0.6.3': dependencies: langium: 3.3.1 @@ -4266,6 +4278,10 @@ snapshots: character-reference-invalid@2.0.1: {} + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + chevrotain-allstar@0.3.1(chevrotain@11.0.3): dependencies: chevrotain: 11.0.3