Componentes
[gráficas] · spm / d3 / motion

RadialChart

Gráfica circular componible estilo Radix/shadcn con entrada en abanico, explode al hover y confetti opcional.

spmd3motion
@/components/spm/radial-chart
0Ingresos
1'use client';
2 
3/**
4 * RadialChart
5 * ─────────────────────────────────────────────────────────────
6 * Gráfica circular componible, estilo Radix/shadcn (Root +
7 * subcomponentes por punto), pensada para salir a NPM como
8 * paquete propio.
9 *
10 * Qué cambia respecto a la versión anterior (la de una sola
11 * pieza que armaba SVG + leyenda + tooltip + centro internamente):
12 * - Antes: <RadialChart data={...} showLegend showConfetti .../>
13 * era un componente cerrado. Cualquier ajuste de layout
14 * (dónde va la leyenda, si hay tooltip, qué muestra el
15 * centro) dependía de props booleanos que vos tenías que
16 * adivinar/leer en el código fuente.
17 * - Ahora: composición libre, igual que <Tabs><TabsList>...
18 * Vos armás el árbol como quieras — podés omitir la leyenda,
19 * poner dos tooltips, meter markup propio adentro del centro,
20 * o usar <RadialChart.Chart> solo, sin nada más alrededor.
21 *
22 * Qué NO cambia (a propósito, la lógica de antes sigue igual):
23 * - Las matemáticas del arco con d3 (pie + arc + cornerRadius +
24 * padAngle).
25 * - La entrada "en abanico" (cada gajo entra rotado/escalado
26 * desde su ángulo medio) y el "explode" al hacer hover.
27 * - El conteo animado del número central con d3.easeCubicOut.
28 * - La paleta "papel picado" por default.
29 *
30 * Composición típica:
31 *
32 * <RadialChart data={ventas} size={320}>
33 * <RadialChart.Chart>
34 * <RadialChart.Confetti />
35 * <RadialChart.CenterLabel subLabel="Piezas vendidas" />
36 * <RadialChart.Tooltip />
37 * </RadialChart.Chart>
38 * <RadialChart.Legend position="right" />
39 * </RadialChart>
40 *
41 * Nota sobre "Confetti": en la versión anterior era un
42 * `showConfetti={true}` hardcodeado adentro del componente
43 * monolítico. Ahora es un subcomponente opt-in: si no lo ponés,
44 * no existe. Igual con el Tooltip: antes salía siempre al hacer
45 * hover; ahora es un <RadialChart.Tooltip /> que agregás si lo
46 * querés (y podés reemplazar por el tuyo propio leyendo el
47 * contexto con el hook `useRadialChart()`, exportado más abajo).
48 */
49 
50import * as React from 'react';
51import * as d3 from 'd3';
52import { motion } from 'motion/react';
53 
54 
55// ─────────────────────────────────────────────────────────────
56// Reemplazá esto por tu `cn` de @/lib/utils (clsx + tailwind-
57// merge) si ya la tenés en el proyecto — la dejo local acá para
58// que el archivo funcione standalone sin dependencias extra.
59// ─────────────────────────────────────────────────────────────
60function cn(...classes) {
61 return classes.filter(Boolean).join(' ');
62}
63 
64const DEFAULT_PALETTE = [
65 '#FF6152', // coral
66 '#08B2A8', // turquesa
67 '#E63E82', // magenta
68 '#FFBB1C', // marigold
69 '#7A5CFA', // violeta
70 '#3FA66B', // hoja
71 '#2E9BE6', // cielo
72 '#C1449E', // ciruela
73];
74 
75const INK = '#2C2118';
76const PAPER = '#FBF3E4';
77 
78// ─────────────────────────────────────────────────────────────
79// Contexto: todo lo que calcula el Root (geometría de d3, estado
80// de hover, estado de animación) baja por acá. Cada subcomponente
81// lee solo lo que necesita — así podés escribir tus propios
82// subcomponentes (un centro custom, una leyenda con otra pinta,
83// un segundo tooltip) sin tocar el Root.
84// ─────────────────────────────────────────────────────────────
85const RadialChartContext = React.createContext(null);
86 
87function useRadialChart() {
88 const ctx = React.useContext(RadialChartContext);
89 if (!ctx) {
90 throw new Error(
91 'RadialChart.* (o useRadialChart) solo se puede usar adentro de <RadialChart>.'
92 );
93 }
94 return ctx;
95}
96 
97const RadialChartRoot = React.forwardRef(function RadialChartRoot(
98 {
99 data,
100 size = 300,
101 
102 // Geometría
103 thickness = 22,
104 innerRadius =100,
105 padAngle = 0.058,
106 cornerRadius = 22,
107 
108 // Animación
109 animated = true,
110 animationDuration = 760,
111 animationStagger = 45,
112 animationEasing = "cubicOut",
113 
114 // Hover
115 explodeOnHover = true,
116 explodeDistance = 10,
117 
118 // Apariencia
119 colors,
120 
121 // Control externo
122 animationKey,
123 
124 // API
125 valueFormatter,
126 onSegmentClick,
127 onSegmentHover,
128 
129 className,
130 children,
131 ...props
132 },
133 ref
134) {
135 const palette = React.useMemo(
136 () => (colors?.length ? colors : DEFAULT_PALETTE),
137 [colors]
138 );
139 
140 const radius = size / 2;
141 
142 const outerR = radius;
143 
144 const innerR = React.useMemo(() => {
145 if (innerRadius != null) {
146 return Math.max(0, Math.min(innerRadius, outerR - 1));
147 }
148 
149 return Math.max(0, outerR - thickness);
150 }, [innerRadius, outerR, thickness]);
151 
152 const fmt =
153 valueFormatter ||
154 ((value) => Number(value).toLocaleString("es-MX"));
155 
156 const total = React.useMemo(
157 () =>
158 data.reduce(
159 (sum, item) => sum + (Number(item.value) || 0),
160 0
161 ),
162 [data]
163 );
164 
165 const pieData = React.useMemo(() => {
166 const pieGen = d3
167 .pie()
168 .value((d) => Number(d.value) || 0)
169 .sort(null)
170 .padAngle(padAngle);
171 
172 return pieGen(data).map((segment, index) => ({
173 ...segment,
174 color:
175 segment.data.color ||
176 palette[index % palette.length],
177 }));
178 }, [data, padAngle, palette]);
179 
180 const arcGen = React.useMemo(
181 () =>
182 d3
183 .arc()
184 .innerRadius(innerR)
185 .outerRadius(outerR)
186 .cornerRadius(cornerRadius)
187 .padRadius(outerR),
188 [innerR, outerR, cornerRadius]
189 );
190 
191 const [hovered, setHovered] = React.useState(null);
192 
193 const [entered, setEntered] = React.useState(!animated);
194 const [settled, setSettled] = React.useState(!animated);
195 
196 const [replayTick, setReplayTick] = React.useState(0);
197 
198 const effectiveAnimationKey =
199 animationKey ?? replayTick;
200 
201 React.useEffect(() => {
202 if (!animated) {
203 setEntered(true);
204 setSettled(true);
205 return;
206 }
207 
208 setEntered(false);
209 setSettled(false);
210 setHovered(null);
211 
212 let frame1;
213 let frame2;
214 
215 frame1 = requestAnimationFrame(() => {
216 frame2 = requestAnimationFrame(() => {
217 setEntered(true);
218 });
219 });
220 
221 const settleTimer = window.setTimeout(() => {
222 setSettled(true);
223 }, animationStagger * data.length + animationDuration);
224 
225 return () => {
226 cancelAnimationFrame(frame1);
227 cancelAnimationFrame(frame2);
228 window.clearTimeout(settleTimer);
229 };
230 }, [
231 effectiveAnimationKey,
232 animated,
233 animationDuration,
234 animationStagger,
235 data.length,
236 ]);
237 
238 const [displayTotal, setDisplayTotal] = React.useState(
239 animated ? 0 : total
240 );
241 
242 React.useEffect(() => {
243 if (!animated) {
244 setDisplayTotal(total);
245 return;
246 }
247 
248 let raf;
249 
250 const start = performance.now();
251 
252 function tick(now) {
253 const progress = Math.min(
254 1,
255 (now - start) / animationDuration
256 );
257 
258 const eased =
259 d3.easeCubicOut(progress);
260 
261 setDisplayTotal(
262 Math.round(total * eased)
263 );
264 
265 if (progress < 1) {
266 raf = requestAnimationFrame(tick);
267 }
268 }
269 
270 raf = requestAnimationFrame(tick);
271 
272 return () => {
273 if (raf) {
274 cancelAnimationFrame(raf);
275 }
276 };
277 }, [
278 total,
279 animated,
280 animationDuration,
281 effectiveAnimationKey,
282 ]);
283 
284 React.useImperativeHandle(
285 ref,
286 () => ({
287 replay() {
288 setReplayTick((tick) => tick + 1);
289 },
290 
291 getHoveredIndex() {
292 return hovered;
293 },
294 
295 getTotal() {
296 return total;
297 },
298 }),
299 [hovered, total]
300 );
301 
302 const contextValue = React.useMemo(
303 () => ({
304 data,
305 pieData,
306 
307 arcGen,
308 
309 size,
310 radius,
311 outerR,
312 innerR,
313 
314 palette,
315 
316 hovered,
317 setHovered,
318 
319 entered,
320 settled,
321 
322 total,
323 displayTotal,
324 fmt,
325 
326 explodeOnHover,
327 explodeDistance,
328 
329 onSegmentClick,
330 onSegmentHover,
331 
332 animated,
333 
334 animationKey: effectiveAnimationKey,
335 
336 animationDuration,
337 animationStagger,
338 animationEasing,
339 }),
340 [
341 data,
342 pieData,
343 arcGen,
344 
345 size,
346 radius,
347 outerR,
348 innerR,
349 
350 palette,
351 
352 hovered,
353 entered,
354 settled,
355 
356 total,
357 displayTotal,
358 fmt,
359 
360 explodeOnHover,
361 explodeDistance,
362 
363 onSegmentClick,
364 onSegmentHover,
365 
366 animated,
367 
368 effectiveAnimationKey,
369 
370 animationDuration,
371 animationStagger,
372 animationEasing,
373 ]
374 );
375 
376 return (
377 <RadialChartContext.Provider value={contextValue}>
378 <div
379 data-slot="radial-chart"
380 className={cn(
381 "flex flex-col items-center gap-8 sm:flex-row",
382 className
383 )}
384 {...props}
385 >
386 {children}
387 </div>
388 </RadialChartContext.Provider>
389 );
390});
391 
392function RadialChartChart({ className, children, ...props }) {
393 const {
394 pieData,
395 arcGen,
396 radius,
397 size,
398 hovered,
399 setHovered,
400 entered,
401 settled,
402 explodeOnHover,
403 explodeDistance,
404 onSegmentClick,
405 onSegmentHover,
406 } = useRadialChart();
407 
408 return (
409 <div
410 data-slot="radial-chart-viz"
411 className={cn('relative shrink-0', className)}
412 style={{ width: size, height: size }}
413 {...props}
414 >
415 <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="overflow-visible">
416 <g transform={`translate(${radius},${radius})`}>
417 {pieData.map((d, i) => {
418 const isHovered = hovered === i;
419 const [cx, cy] = arcGen.centroid(d);
420 const mid = (d.startAngle + d.endAngle) / 2;
421 const dirx = Math.sin(mid);
422 const diry = -Math.cos(mid);
423 
424 const hoverTx = explodeOnHover && isHovered ? dirx * explodeDistance : 0;
425 const hoverTy = explodeOnHover && isHovered ? diry * explodeDistance : 0;
426 const hoverScale = explodeOnHover && isHovered ? 1.045 : 1;
427 
428 const entranceTx = entered ? 0 : dirx * 70;
429 const entranceTy = entered ? 0 : diry * 70;
430 const entranceScale = entered ? 1 : 0.25;
431 const entranceRot = entered ? 0 : i % 2 === 0 ? -55 : 55;
432 const entranceOpacity = entered ? 1 : 0;
433 
434 const delay = settled ? 0 : i * 70;
435 
436 return (
437 <g
438 key={`${d.data.label}-${i}`}
439 style={{
440 transform: `translate(${entranceTx + hoverTx}px, ${entranceTy + hoverTy}px) rotate(${entranceRot}deg) scale(${entranceScale * hoverScale})`,
441 transformOrigin: `${cx}px ${cy}px`,
442 opacity: entranceOpacity,
443 transitionProperty: 'transform, opacity, filter',
444 transitionDuration: '460ms, 380ms, 260ms',
445 transitionTimingFunction: 'cubic-bezier(.34,1.56,.64,1)',
446 transitionDelay: `${delay}ms`,
447 cursor: onSegmentClick ? 'pointer' : 'default',
448 filter: isHovered
449 ? `drop-shadow(0 12px 16px ${d.color}66) drop-shadow(0 2px 4px rgba(0,0,0,.28))`
450 : `drop-shadow(0 3px 6px rgba(43,33,24,.18))`,
451 }}
452 onMouseEnter={() => {
453 setHovered(i);
454 onSegmentHover && onSegmentHover(d.data, i);
455 }}
456 onMouseLeave={() => setHovered(null)}
457 onClick={() => onSegmentClick && onSegmentClick(d.data, i)}
458 >
459 <path d={arcGen(d)} fill={d.color} stroke={PAPER} strokeWidth={2} />
460 <path d={arcGen(d)} fill="none" stroke="rgba(255,255,255,.35)" strokeWidth={1} />
461 </g>
462 );
463 })}
464 </g>
465 </svg>
466 
467 {children}
468 </div>
469 );
470}
471 
472function RadialChartCenterLabel({ subLabel = 'Total', className, children, ...props }) {
473 const { size, displayTotal, total, fmt } = useRadialChart();
474 
475 return (
476 <div
477 data-slot="radial-chart-center"
478 className={cn(
479 'absolute inset-0 flex flex-col items-center justify-center pointer-events-none select-none',
480 className
481 )}
482 {...props}
483 >
484 {typeof children === 'function' ? (
485 children({ total, displayTotal, fmt })
486 ) : children ? (
487 children
488 ) : (
489 <>
490 <span
491 className="font-semibold tabular-nums"
492 style={{ fontFamily: "'Space Grotesk', sans-serif", fontSize: size * 0.135, color: INK }}
493 >
494 {fmt(displayTotal)}
495 </span>
496 <span
497 className="uppercase tracking-[0.22em] font-medium mt-1"
498 style={{ fontSize: Math.max(10, size * 0.034), color: '#8a7a68' }}
499 >
500 {subLabel}
501 </span>
502 </>
503 )}
504 </div>
505 );
506}
507 
508function RadialChartTooltip({ className, formatter, ...props }) {
509 const { hovered, pieData, arcGen, radius, total, fmt } = useRadialChart();
510 
511 if (hovered === null || !pieData[hovered]) return null;
512 
513 const d = pieData[hovered];
514 const [cx, cy] = arcGen.centroid(d);
515 const pct = total ? ((d.data.value / total) * 100).toFixed(1) : '0';
516 
517 return (
518 <motion.div
519 layoutId='layoutid-toolip-id-spm-compoenet'
520 transition={{ layout: { duration: 0.5, delay: 0.1 } }}
521 data-slot="radial-chart-tooltip"
522 className={cn(
523 'absolute z-20 pointer-events-none px-3 py-1.5 rounded-lg text-xs font-semibold shadow-lg whitespace-nowrap -translate-x-1/2 -translate-y-[135%] transition-opacity duration-150',
524 className
525 )}
526 style={{ left: radius + cx, top: radius + cy, background: INK, color: PAPER }}
527 {...props}
528 >
529 {formatter ? formatter(d.data, pct) : `${d.data.label} · ${fmt(d.data.value)} · ${pct}%`}
530 </motion.div>
531 );
532}
533 
534function RadialChartConfetti({ count = 16, className, ...props }) {
535 const { size, palette, entered, animationKey, data } = useRadialChart();
536 
537 const particles = React.useMemo(() => {
538 let s = ((animationKey ?? 0) + data.length) * 9973 + 17;
539 const rand = () => {
540 s = (s * 9301 + 49297) % 233280;
541 return s / 233280;
542 };
543 return Array.from({ length: count }).map((_, i) => {
544 const angle = rand() * Math.PI * 2;
545 const dist = size * 0.4 + rand() * size * 0.28;
546 return {
547 id: i,
548 x: Math.cos(angle) * dist,
549 y: Math.sin(angle) * dist,
550 rot: rand() * 360,
551 scale: 0.5 + rand() * 0.8,
552 color: palette[i % palette.length],
553 shape: i % 3,
554 delay: Math.round(rand() * 260),
555 };
556 });
557 }, [animationKey, data.length, size, palette, count]);
558 
559 const [show, setShow] = React.useState(false);
560 
561 React.useEffect(() => {
562 setShow(false);
563 if (!entered) return;
564 let r1, r2;
565 r1 = requestAnimationFrame(() => {
566 r2 = requestAnimationFrame(() => setShow(true));
567 });
568 return () => {
569 cancelAnimationFrame(r1);
570 cancelAnimationFrame(r2);
571 };
572 }, [entered, animationKey]);
573 
574 if (!entered) return null;
575 
576 return (
577 <div
578 data-slot="radial-chart-confetti"
579 className={cn('absolute inset-0 pointer-events-none overflow-visible', className)}
580 {...props}
581 >
582 {particles.map((p) => (
583 <span
584 key={p.id}
585 className="absolute"
586 style={{
587 left: '50%',
588 top: '50%',
589 width: 7,
590 height: 7,
591 background: p.color,
592 borderRadius: p.shape === 0 ? '9999px' : p.shape === 1 ? '2px' : '9999px 9999px 9999px 0',
593 transform: show
594 ? `translate(${p.x}px, ${p.y}px) rotate(${p.rot}deg) scale(${p.scale})`
595 : `translate(0px, 0px) rotate(0deg) scale(0)`,
596 opacity: show ? 0 : 1,
597 transitionProperty: 'transform, opacity',
598 transitionDuration: '900ms, 900ms',
599 transitionTimingFunction: 'cubic-bezier(.16,1,.3,1)',
600 transitionDelay: `${p.delay}ms`,
601 }}
602 />
603 ))}
604 </div>
605 );
606}
607 
608function RadialChartLegend({ position = 'right', className, renderItem, ...props }) {
609 const { pieData, hovered, setHovered, total, fmt } = useRadialChart();
610 
611 return (
612 <ul
613 data-slot="radial-chart-legend"
614 className={cn(
615 'flex',
616 position === 'bottom' ? 'flex-row flex-wrap justify-center' : 'flex-col',
617 'gap-2 min-w-[168px]',
618 className
619 )}
620 {...props}
621 >
622 {pieData.map((d, i) => {
623 const isHovered = hovered === i;
624 
625 if (renderItem) {
626 return (
627 <li key={i} onMouseEnter={() => setHovered(i)} onMouseLeave={() => setHovered(null)}>
628 {renderItem(d.data, i, isHovered)}
629 </li>
630 );
631 }
632 
633 return (
634 <li
635 key={i}
636 onMouseEnter={() => setHovered(i)}
637 onMouseLeave={() => setHovered(null)}
638 className="flex items-center gap-2.5 cursor-default rounded-lg px-2 py-1.5 transition-colors duration-150"
639 style={{ background: isHovered ? `${d.color}1a` : 'transparent' }}
640 >
641 <span
642 className="block w-3 h-3 rounded-[3px] rotate-45 shrink-0 transition-transform duration-200"
643 style={{
644 background: d.color,
645 boxShadow: `0 1px 3px ${d.color}80`,
646 transform: isHovered ? 'rotate(45deg) scale(1.25)' : 'rotate(45deg) scale(1)',
647 }}
648 />
649 <span className="text-sm font-medium flex-1" style={{ color: INK }}>
650 {d.data.label}
651 </span>
652 <span
653 className="text-xs font-semibold tabular-nums"
654 style={{ fontFamily: "'Space Grotesk', sans-serif", color: '#8a7a68' }}
655 >
656 {total ? ((d.data.value / total) * 100).toFixed(0) : 0}%
657 </span>
658 </li>
659 );
660 })}
661 </ul>
662 );
663}
664 
665export const RadialChart = Object.assign(RadialChartRoot, {
666 Chart: RadialChartChart,
667 CenterLabel: RadialChartCenterLabel,
668 Tooltip: RadialChartTooltip,
669 Confetti: RadialChartConfetti,
670 Legend: RadialChartLegend,
671});
672 
673export { useRadialChart };
674 
675 

Uso

Props

PropTipoDefaultDescripción
dataArray<{label, value, color}>—Datos de los gajos.
sizenumber300Diámetro del gráfico en px.
thicknessnumber22Grosor del anillo.
animatedbooleantrueEntrada animada + conteo central.
explodeOnHoverbooleantrueSepara el gajo activo.
colorsstring[]paletaColores por gajo.

Acciones del componente

Search for a command to run...

Potenciá tu negocio con SFESS

Software a medida, IA integrada y ciberseguridad avanzada para empresas.

0años de ingeniería
0proyectos entregados
0% uptime
Servicios destacados
  • Consultoría SEO Avanzado & Analítica

    Auditoría técnica profunda para posicionar tu negocio en los primeros resultados de Google.

    SEO TécnicoGA4
  • Páginas Web Corporativas Complejas

    Plataformas empresariales masivas con múltiples secciones y animaciones fluidas.

    Framer MotionMulti-page
  • Integración de Inteligencia Artificial en Negocios

    Automatizamos la atención, las reservas y las ventas de tu negocio con agentes de IA.

    AI AgentsChatbots
Nuestro ecosistema
WORKENTA_OSSFESS_SOLUTIONSPRANALUZ_YOGAREMY_AI
Ver todos los servicios