[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-chartRadialChart
0Ingresos
0Ingresos
1'use client';2 3/**4 * RadialChart5 * ─────────────────────────────────────────────────────────────6 * Gráfica circular componible, estilo Radix/shadcn (Root +7 * subcomponentes por punto), pensada para salir a NPM como8 * paquete propio.9 *10 * Qué cambia respecto a la versión anterior (la de una sola11 * pieza que armaba SVG + leyenda + tooltip + centro internamente):12 * - Antes: <RadialChart data={...} showLegend showConfetti .../>13 * era un componente cerrado. Cualquier ajuste de layout14 * (dónde va la leyenda, si hay tooltip, qué muestra el15 * centro) dependía de props booleanos que vos tenías que16 * 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/escalado26 * 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 un42 * `showConfetti={true}` hardcodeado adentro del componente43 * 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 hacer45 * hover; ahora es un <RadialChart.Tooltip /> que agregás si lo46 * querés (y podés reemplazar por el tuyo propio leyendo el47 * 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á para58// 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', // coral66 '#08B2A8', // turquesa67 '#E63E82', // magenta68 '#FFBB1C', // marigold69 '#7A5CFA', // violeta70 '#3FA66B', // hoja71 '#2E9BE6', // cielo72 '#C1449E', // ciruela73];74 75const INK = '#2C2118';76const PAPER = '#FBF3E4';77 78// ─────────────────────────────────────────────────────────────79// Contexto: todo lo que calcula el Root (geometría de d3, estado80// de hover, estado de animación) baja por acá. Cada subcomponente81// lee solo lo que necesita — así podés escribir tus propios82// 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ía103 thickness = 22,104 innerRadius =100,105 padAngle = 0.058,106 cornerRadius = 22,107 108 // Animación109 animated = true,110 animationDuration = 760,111 animationStagger = 45,112 animationEasing = "cubicOut",113 114 // Hover115 explodeOnHover = true,116 explodeDistance = 10,117 118 // Apariencia119 colors,120 121 // Control externo122 animationKey,123 124 // API125 valueFormatter,126 onSegmentClick,127 onSegmentHover,128 129 className,130 children,131 ...props132 },133 ref134) {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 0161 ),162 [data]163 );164 165 const pieData = React.useMemo(() => {166 const pieGen = d3167 .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 d3183 .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 : total240 );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) / animationDuration256 );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 <div379 data-slot="radial-chart"380 className={cn(381 "flex flex-col items-center gap-8 sm:flex-row",382 className383 )}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 <div410 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 <g438 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: isHovered449 ? `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 <div477 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 className481 )}482 {...props}483 >484 {typeof children === 'function' ? (485 children({ total, displayTotal, fmt })486 ) : children ? (487 children488 ) : (489 <>490 <span491 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 <span497 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.div519 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 className525 )}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 <div578 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 <span584 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: show594 ? `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 <ul613 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 className619 )}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 <li635 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 <span642 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 <span653 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écnicoGA4Páginas Web Corporativas Complejas
Plataformas empresariales masivas con múltiples secciones y animaciones fluidas.
Framer MotionMulti-pageIntegració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
Ver todos los serviciosWORKENTA_OSSFESS_SOLUTIONSPRANALUZ_YOGAREMY_AI