Spaces:
Sleeping
Sleeping
| import { memo, useState, useEffect, useRef } from "react"; | |
| import type { ReactElement } from "react"; | |
| interface ChartData { | |
| labels: string[]; | |
| datasets: Array<{ label: string; data: number[]; color?: string }>; | |
| type?: "bar" | "line" | "pie" | "doughnut"; | |
| title?: string; | |
| } | |
| function parseChartData(raw: string): ChartData | null { | |
| try { | |
| return JSON.parse(raw) as ChartData; | |
| } catch { | |
| // try to extract JSON from markdown | |
| const match = raw.match(/```(?:json)?\s*([\s\S]*?)```/); | |
| if (match) { | |
| try { return JSON.parse(match[1]) as ChartData; } catch { /* continue */ } | |
| } | |
| return null; | |
| } | |
| } | |
| function polarToCart(cx: number, cy: number, r: number, angle: number) { | |
| return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) }; | |
| } | |
| function PieChart({ data, w = 240, h = 200 }: { data: ChartData; w?: number; h?: number }) { | |
| const cx = w / 2 - 10, cy = h / 2; | |
| const r = Math.min(cx, cy) - 20; | |
| const COLORS = ["#4f8ef7","#7c4dff","#22c55e","#f59e0b","#ef4444","#06b6d4","#ec4899","#a3e635"]; | |
| const all = data.datasets.flatMap(d => d.data); | |
| const total = all.reduce((a, b) => a + b, 0); | |
| if (total === 0) return null; | |
| const slices: ReactElement[] = []; | |
| let startAngle = -Math.PI / 2; | |
| all.forEach((val, i) => { | |
| const angle = (val / total) * 2 * Math.PI; | |
| const end = startAngle + angle; | |
| const mid = startAngle + angle / 2; | |
| const p1 = polarToCart(cx, cy, r, startAngle); | |
| const p2 = polarToCart(cx, cy, r, end); | |
| const largeArc = angle > Math.PI ? 1 : 0; | |
| const color = COLORS[i % COLORS.length]; | |
| slices.push( | |
| <path key={i} | |
| d={`M ${cx} ${cy} L ${p1.x} ${p1.y} A ${r} ${r} 0 ${largeArc} 1 ${p2.x} ${p2.y} Z`} | |
| fill={color} stroke="#0d0d18" strokeWidth={2} | |
| style={{ transition: "opacity 0.12s" }} | |
| />, | |
| val / total > 0.05 ? ( | |
| <text key={`l${i}`} | |
| x={polarToCart(cx, cy, r * 0.65, mid).x} | |
| y={polarToCart(cx, cy, r * 0.65, mid).y} | |
| textAnchor="middle" dominantBaseline="middle" | |
| fill="#fff" fontSize={9} fontWeight="600"> | |
| {Math.round((val / total) * 100)}% | |
| </text> | |
| ) : <></>, | |
| ); | |
| startAngle = end; | |
| }); | |
| const labels = data.labels || all.map((_, i) => `Item ${i+1}`); | |
| return ( | |
| <svg width={w} height={h}> | |
| {slices} | |
| {labels.slice(0, 6).map((l, i) => ( | |
| <g key={i} transform={`translate(${w - 70}, ${20 + i * 18})`}> | |
| <rect width={10} height={10} rx={2} fill={COLORS[i % COLORS.length]} /> | |
| <text x={14} y={9} fill="#9090a8" fontSize={9}>{l.slice(0, 10)}</text> | |
| </g> | |
| ))} | |
| </svg> | |
| ); | |
| } | |
| const ChartBlock = memo(function ChartBlock({ content }: { content: string }) { | |
| const canvasRef = useRef<HTMLCanvasElement>(null); | |
| const [error, _setError] = useState<string | null>(null); | |
| const data = parseChartData(content); | |
| useEffect(() => { | |
| if (!data || !canvasRef.current) return; | |
| if (data.type === "pie" || data.type === "doughnut") return; | |
| const canvas = canvasRef.current; | |
| const ctx = canvas.getContext("2d"); | |
| if (!ctx) return; | |
| const COLORS = ["#4f8ef7","#7c4dff","#22c55e","#f59e0b","#ef4444","#06b6d4","#ec4899"]; | |
| const w = canvas.width, h = canvas.height; | |
| const pad = { top: 20, right: 20, bottom: 35, left: 40 }; | |
| const chartW = w - pad.left - pad.right; | |
| const chartH = h - pad.top - pad.bottom; | |
| ctx.clearRect(0, 0, w, h); | |
| const allVals = data.datasets.flatMap(d => d.data); | |
| const maxVal = Math.max(...allVals, 1); | |
| const minVal = Math.min(0, ...allVals); | |
| // Grid | |
| ctx.strokeStyle = "#1e1e2e"; | |
| ctx.lineWidth = 1; | |
| const ySteps = 4; | |
| for (let i = 0; i <= ySteps; i++) { | |
| const y = pad.top + (i / ySteps) * chartH; | |
| ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + chartW, y); ctx.stroke(); | |
| const val = maxVal - (i / ySteps) * (maxVal - minVal); | |
| ctx.fillStyle = "#5a5a72"; ctx.font = "9px sans-serif"; ctx.textAlign = "right"; | |
| ctx.fillText(val.toFixed(1), pad.left - 4, y + 3); | |
| } | |
| const barGroupW = chartW / data.labels.length; | |
| const barCount = data.datasets.length; | |
| const barW = Math.min(barGroupW * 0.7 / barCount, 40); | |
| const yZero = pad.top + chartH * (maxVal / (maxVal - minVal)); | |
| data.datasets.forEach((ds, di) => { | |
| ctx.fillStyle = ds.color || COLORS[di % COLORS.length]; | |
| ctx.strokeStyle = ds.color || COLORS[di % COLORS.length]; | |
| ctx.lineWidth = 2; | |
| if (data.type === "line") { | |
| ctx.beginPath(); | |
| ds.data.forEach((val, xi) => { | |
| const x = pad.left + barGroupW * (xi + 0.5); | |
| const y = pad.top + chartH * (1 - (val - minVal) / (maxVal - minVal)); | |
| if (xi === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); | |
| }); | |
| ctx.stroke(); | |
| ds.data.forEach((val, xi) => { | |
| const x = pad.left + barGroupW * (xi + 0.5); | |
| const y = pad.top + chartH * (1 - (val - minVal) / (maxVal - minVal)); | |
| ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill(); | |
| }); | |
| } else { | |
| ds.data.forEach((val, xi) => { | |
| const x = pad.left + barGroupW * xi + (barGroupW - barCount * barW) / 2 + di * barW; | |
| const barH = Math.abs((val / (maxVal - minVal)) * chartH); | |
| const y = val >= 0 ? yZero - barH : yZero; | |
| ctx.fillRect(x, y, barW - 2, barH); | |
| }); | |
| } | |
| }); | |
| // X labels | |
| ctx.fillStyle = "#5a5a72"; ctx.font = "9px sans-serif"; ctx.textAlign = "center"; | |
| data.labels.forEach((l, i) => { | |
| const x = pad.left + barGroupW * (i + 0.5); | |
| ctx.fillText(l.slice(0, 10), x, pad.top + chartH + 14); | |
| }); | |
| }, [data]); | |
| if (!data) { | |
| return ( | |
| <div style={{ padding: "0.5rem 0.75rem", background: "#12121f", borderRadius: 8, border: "1px solid #222236", color: "#fca5a5", fontSize: "0.78rem" }}> | |
| Formato grafico non valido. Usa JSON con: labels, datasets[].data, type (bar|line|pie). | |
| </div> | |
| ); | |
| } | |
| const isPie = data.type === "pie" || data.type === "doughnut"; | |
| return ( | |
| <div style={{ background: "#0d0d18", borderRadius: 10, border: "1px solid #1e1e2e", overflow: "hidden", marginBottom: "0.6em" }}> | |
| {data.title && ( | |
| <div style={{ padding: "0.5rem 0.85rem", borderBottom: "1px solid #1e1e2e", fontSize: "0.8rem", fontWeight: 600, color: "#c0c0d0" }}> | |
| {data.title} | |
| </div> | |
| )} | |
| <div style={{ padding: "0.75rem", display: "flex", justifyContent: "center" }}> | |
| {isPie ? ( | |
| <PieChart data={data} /> | |
| ) : ( | |
| <canvas ref={canvasRef} width={420} height={220} style={{ maxWidth: "100%" }} /> | |
| )} | |
| </div> | |
| {error && <div style={{ padding: "0.4rem 0.85rem", color: "#fca5a5", fontSize: "0.75rem" }}>{error}</div>} | |
| </div> | |
| ); | |
| } | |
| ); | |
| export default ChartBlock; | |