252 lines
10 KiB
TypeScript
252 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo, useState, useEffect } from 'react'
|
|
import {
|
|
BarChart, Bar, LineChart, Line, AreaChart, Area,
|
|
PieChart, Pie, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis,
|
|
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
|
FunnelChart, Funnel, LabelList,
|
|
} from 'recharts'
|
|
|
|
const CHART_COLORS = ['#6366f1', '#22d3ee', '#f59e0b', '#ef4444', '#10b981', '#a78bfa', '#fb923c', '#14b8a6']
|
|
|
|
interface ChartData {
|
|
label: string
|
|
value: number
|
|
}
|
|
|
|
interface NoteChartProps {
|
|
type: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'radar' | 'funnel' | 'gauge'
|
|
title?: string
|
|
data: ChartData[]
|
|
colors?: string[]
|
|
showLegend?: boolean
|
|
height?: number
|
|
}
|
|
|
|
// Hook to detect dark mode without re-render loops
|
|
function useDarkMode() {
|
|
const [isDark, setIsDark] = useState(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
|
|
|
|
useEffect(() => {
|
|
const checkDarkMode = () => {
|
|
setIsDark(document.documentElement.classList.contains('dark'))
|
|
}
|
|
|
|
// Initial check
|
|
checkDarkMode()
|
|
|
|
// Listen for class changes on html element
|
|
const observer = new MutationObserver(checkDarkMode)
|
|
observer.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ['class'],
|
|
})
|
|
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
return isDark
|
|
}
|
|
|
|
function NoteChart({ type, title, data, colors, showLegend, height = 250 }: NoteChartProps) {
|
|
const chartColors = colors ?? CHART_COLORS
|
|
const dark = useDarkMode()
|
|
|
|
const ttStyle = {
|
|
background: dark ? '#1C1C1C' : '#fff',
|
|
border: dark ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)',
|
|
borderRadius: 8,
|
|
fontSize: 12,
|
|
color: dark ? '#fff' : '#000',
|
|
}
|
|
|
|
const tickStyle = { fill: dark ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.55)', fontSize: 11 }
|
|
const gridStroke = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'
|
|
const legendStyle = { fontSize: 12, color: dark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)' }
|
|
|
|
const xKey = 'label'
|
|
const yKeys = ['value']
|
|
|
|
const renderChart = () => {
|
|
switch (type) {
|
|
case 'bar':
|
|
return (
|
|
<ResponsiveContainer width="100%" height={height}>
|
|
<BarChart data={data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
|
<XAxis dataKey={xKey} tick={tickStyle} axisLine={false} tickLine={false} />
|
|
<YAxis tick={tickStyle} axisLine={false} tickLine={false} width={40} />
|
|
<Tooltip contentStyle={ttStyle} cursor={{ fill: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)' }} />
|
|
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
|
|
<Bar dataKey={yKeys[0]} fill={chartColors[0]} radius={[4, 4, 0, 0]} maxBarSize={56} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
)
|
|
|
|
case 'horizontal-bar':
|
|
return (
|
|
<ResponsiveContainer width="100%" height={height}>
|
|
<BarChart data={data} layout="vertical" margin={{ top: 8, right: 24, left: 60, bottom: 4 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
|
<XAxis type="number" tick={tickStyle} axisLine={false} tickLine={false} />
|
|
<YAxis dataKey={xKey} type="category" tick={tickStyle} axisLine={false} tickLine={false} width={55} />
|
|
<Tooltip contentStyle={ttStyle} cursor={{ fill: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)' }} />
|
|
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
|
|
<Bar dataKey={yKeys[0]} fill={chartColors[0]} radius={[0, 4, 4, 0]} maxBarSize={56} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
)
|
|
|
|
case 'line':
|
|
return (
|
|
<ResponsiveContainer width="100%" height={height}>
|
|
<LineChart data={data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
|
<XAxis dataKey={xKey} tick={tickStyle} axisLine={false} tickLine={false} />
|
|
<YAxis tick={tickStyle} axisLine={false} tickLine={false} width={40} />
|
|
<Tooltip contentStyle={ttStyle} />
|
|
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
|
|
<Line type="monotone" dataKey={yKeys[0]} stroke={chartColors[0]} strokeWidth={2.5} dot={{ r: 4, strokeWidth: 0 }} activeDot={{ r: 6 }} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
)
|
|
|
|
case 'area':
|
|
return (
|
|
<ResponsiveContainer width="100%" height={height}>
|
|
<AreaChart data={data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
|
|
<XAxis dataKey={xKey} tick={tickStyle} axisLine={false} tickLine={false} />
|
|
<YAxis tick={tickStyle} axisLine={false} tickLine={false} width={40} />
|
|
<Tooltip contentStyle={ttStyle} />
|
|
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
|
|
<Area type="monotone" dataKey={yKeys[0]} stroke={chartColors[0]} fill={`${chartColors[0]}28`} strokeWidth={2.5} />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
)
|
|
|
|
case 'pie':
|
|
return (
|
|
<ResponsiveContainer width="100%" height={height}>
|
|
<PieChart>
|
|
<Pie data={data} dataKey={yKeys[0]} nameKey={xKey} cx="50%" cy="50%" outerRadius="70%" innerRadius="35%" paddingAngle={2}>
|
|
{data.map((_, i) => <Cell key={i} fill={chartColors[i % chartColors.length]} stroke="transparent" />)}
|
|
</Pie>
|
|
<Tooltip contentStyle={ttStyle} />
|
|
{showLegend !== false && <Legend wrapperStyle={legendStyle} />}
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
)
|
|
|
|
case 'radar':
|
|
return (
|
|
<ResponsiveContainer width="100%" height={height}>
|
|
<RadarChart data={data} cx="50%" cy="50%">
|
|
<PolarGrid stroke={dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'} />
|
|
<PolarAngleAxis dataKey={xKey} tick={{ fill: dark ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)', fontSize: 11 }} />
|
|
<Radar name={title || 'Value'} dataKey={yKeys[0]} stroke={chartColors[0]} fill={chartColors[0]} fillOpacity={0.15} />
|
|
<Tooltip contentStyle={ttStyle} />
|
|
</RadarChart>
|
|
</ResponsiveContainer>
|
|
)
|
|
|
|
case 'funnel':
|
|
return (
|
|
<ResponsiveContainer width="100%" height={height}>
|
|
<FunnelChart>
|
|
<Tooltip contentStyle={ttStyle} />
|
|
<Funnel dataKey={yKeys[0]} data={data.map((d, i) => ({ ...d, fill: chartColors[i % chartColors.length] }))} isAnimationActive>
|
|
<LabelList position="center" fill={dark ? '#fff' : '#000'} fontSize={12} fontWeight={700} dataKey={xKey} />
|
|
</Funnel>
|
|
</FunnelChart>
|
|
</ResponsiveContainer>
|
|
)
|
|
|
|
case 'gauge': {
|
|
const value = data[0]?.value ?? 0
|
|
const max = Math.max(...data.map(d => d.value), 100)
|
|
const pct = Math.min(Math.max(value / max, 0), 1)
|
|
const color = pct > 0.7 ? '#10b981' : pct > 0.4 ? '#f59e0b' : '#ef4444'
|
|
return (
|
|
<div style={{ width: '100%', height: `${height}px`, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
|
<svg viewBox="0 0 200 120" style={{ width: '60%', maxHeight: '70%' }}>
|
|
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke={dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'} strokeWidth="16" strokeLinecap="round" />
|
|
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke={color} strokeWidth="16" strokeLinecap="round"
|
|
strokeDasharray={`${pct * 251.2} 251.2`} />
|
|
<text x="100" y="90" textAnchor="middle" fill={dark ? '#fff' : '#000'} fontSize="28" fontWeight="800">{value}</text>
|
|
<text x="100" y="112" textAnchor="middle" fill={dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'} fontSize="11">{title || 'Value'}</text>
|
|
</svg>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
default:
|
|
return <div className="text-center text-muted-foreground py-8">Type de graphique non supporté: {type}</div>
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="my-6 rounded-xl border border-border bg-card p-4">
|
|
{title && <h4 className="text-sm font-semibold text-foreground mb-4">{title}</h4>}
|
|
{renderChart()}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Parse chart from JSON or simple format
|
|
export function parseChartFromCode(code: string): NoteChartProps | null {
|
|
try {
|
|
// Try JSON first
|
|
const parsed = JSON.parse(code)
|
|
if (parsed.type && parsed.data && Array.isArray(parsed.data)) {
|
|
return {
|
|
type: parsed.type,
|
|
title: parsed.title,
|
|
data: parsed.data,
|
|
colors: parsed.colors,
|
|
showLegend: parsed.showLegend,
|
|
height: parsed.height,
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// Try simple format: type, title, then label: value lines
|
|
const lines = code.trim().split('\n').map(l => l.trim()).filter(Boolean)
|
|
if (lines.length === 0) return null
|
|
|
|
const firstLine = lines[0].toLowerCase()
|
|
let type: NoteChartProps['type'] = 'bar'
|
|
let title = ''
|
|
|
|
if (['bar', 'horizontal-bar', 'line', 'area', 'pie', 'radar', 'funnel', 'gauge'].includes(firstLine)) {
|
|
type = firstLine as NoteChartProps['type']
|
|
} else if (lines[0].includes(':')) {
|
|
// No type specified, default to bar
|
|
} else {
|
|
title = lines[0]
|
|
}
|
|
|
|
const data: ChartData[] = []
|
|
for (const line of lines.slice(title ? 1 : type !== lines[0]?.toLowerCase() ? 0 : 1)) {
|
|
const parts = line.split(/[:|]/)
|
|
if (parts.length >= 2) {
|
|
const label = parts[0].trim()
|
|
const value = parseFloat(parts[1].trim())
|
|
if (!isNaN(value)) {
|
|
data.push({ label, value })
|
|
}
|
|
}
|
|
}
|
|
|
|
if (data.length === 0) return null
|
|
|
|
return { type, title: title || undefined, data }
|
|
}
|
|
|
|
export function NoteChartFromCode({ code }: { code: string }) {
|
|
const props = useMemo(() => parseChartFromCode(code), [code])
|
|
if (!props) return <div className="text-center text-muted-foreground py-4">Format de graphique invalide</div>
|
|
return <NoteChart {...props} />
|
|
}
|