@yewdiel21/motion-kit 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.md +178 -155
  2. package/bin/motion-kit.mjs +3 -3
  3. package/dist/auto/index.d.ts +6 -3
  4. package/dist/auto/index.js +8 -2
  5. package/dist/auto/index.js.map +1 -1
  6. package/dist/{chunk-EY4BJKC4.js → chunk-NFZIX3IA.js} +2 -2
  7. package/dist/chunk-RBEKR5UZ.js +3660 -0
  8. package/dist/chunk-RBEKR5UZ.js.map +1 -0
  9. package/dist/{chunk-CU6SXW3E.js → chunk-SDQ776LG.js} +1596 -485
  10. package/dist/chunk-SDQ776LG.js.map +1 -0
  11. package/dist/css/components/add-to-cart.css +72 -0
  12. package/dist/css/components/bidirectional-slider.css +168 -0
  13. package/dist/css/components/chart.css +7 -0
  14. package/dist/css/components/data-table.css +121 -26
  15. package/dist/css/components/filter-builder.css +10 -15
  16. package/dist/css/components/heatmap-chart.css +398 -0
  17. package/dist/css/components/hover-card.css +8 -2
  18. package/dist/css/components/image-preview.css +90 -2
  19. package/dist/css/components/json-viewer.css +33 -1
  20. package/dist/css/components/nav.css +102 -102
  21. package/dist/css/components/orbit-menu.css +130 -0
  22. package/dist/css/components/pagination.css +10 -5
  23. package/dist/css/components/pie-chart.css +397 -0
  24. package/dist/css/components/pill-button.css +135 -0
  25. package/dist/css/components/radar-chart.css +377 -0
  26. package/dist/css/components/radial-chart.css +412 -0
  27. package/dist/css/components/sankey-chart.css +365 -0
  28. package/dist/css/components/scroll-island.css +114 -0
  29. package/dist/css/components/select.css +136 -0
  30. package/dist/css/components/toolbar.css +122 -0
  31. package/dist/css/components/tree-view.css +79 -0
  32. package/dist/css/components/voice-dots.css +70 -0
  33. package/dist/elements/index.d.ts +180 -7
  34. package/dist/elements/index.js +293 -7
  35. package/dist/elements/index.js.map +1 -1
  36. package/dist/{engine-Cn6IhE3z.d.ts → engine-5zRIssTO.d.ts} +11 -1
  37. package/dist/index.css +1849 -168
  38. package/dist/index.d.ts +4 -4
  39. package/dist/index.js +33 -3
  40. package/dist/index.js.map +1 -1
  41. package/dist/react/index.d.ts +117 -11
  42. package/dist/react/index.js +320 -13
  43. package/dist/react/index.js.map +1 -1
  44. package/dist/{reduced-BBUusld6.d.ts → reduced-DQwVXQYb.d.ts} +1 -1
  45. package/dist/{reveal-DEvp6U6T.d.ts → reveal-DRpNO4KP.d.ts} +109 -4
  46. package/dist/store-Da3zSh8Z.d.ts +457 -0
  47. package/package.json +9 -16
  48. package/registry.json +1096 -948
  49. package/src/auto/auto-init.ts +6 -0
  50. package/src/components/add-to-cart/README.md +49 -0
  51. package/src/components/add-to-cart/add-to-cart.css +71 -0
  52. package/src/components/add-to-cart/element.ts +20 -0
  53. package/src/components/add-to-cart/engine.ts +206 -0
  54. package/src/components/add-to-cart/react.tsx +33 -0
  55. package/src/components/bidirectional-slider/README.md +46 -0
  56. package/src/components/bidirectional-slider/bidirectional-slider.css +167 -0
  57. package/src/components/bidirectional-slider/element.ts +42 -0
  58. package/src/components/bidirectional-slider/engine.ts +289 -0
  59. package/src/components/bidirectional-slider/react.tsx +45 -0
  60. package/src/components/chart/README.md +49 -24
  61. package/src/components/chart/chart.css +7 -0
  62. package/src/components/chart/element.ts +4 -1
  63. package/src/components/chart/engine.ts +153 -34
  64. package/src/components/chart/parts.ts +237 -0
  65. package/src/components/chart/react.tsx +2 -2
  66. package/src/components/chart/use-chart.ts +55 -0
  67. package/src/components/data-table/README.md +3 -3
  68. package/src/components/data-table/data-table.css +121 -26
  69. package/src/components/data-table/engine.ts +81 -29
  70. package/src/components/editable-table/engine.ts +67 -11
  71. package/src/components/filter-builder/engine.ts +33 -2
  72. package/src/components/filter-builder/filter-builder.css +10 -15
  73. package/src/components/heatmap-chart/README.md +48 -0
  74. package/src/components/heatmap-chart/element.ts +21 -0
  75. package/src/components/heatmap-chart/engine.ts +281 -0
  76. package/src/components/heatmap-chart/heatmap-chart.css +93 -0
  77. package/src/components/heatmap-chart/react.tsx +14 -0
  78. package/src/components/hover-card/engine.ts +30 -1
  79. package/src/components/hover-card/hover-card.css +8 -2
  80. package/src/components/image-preview/element.ts +1 -1
  81. package/src/components/image-preview/engine.ts +95 -15
  82. package/src/components/image-preview/image-preview.css +90 -2
  83. package/src/components/image-preview/react.tsx +3 -3
  84. package/src/components/json-viewer/engine.ts +11 -2
  85. package/src/components/json-viewer/json-viewer.css +33 -1
  86. package/src/components/nav/README.md +12 -10
  87. package/src/components/nav/element.ts +5 -5
  88. package/src/components/nav/engine.ts +21 -14
  89. package/src/components/nav/nav.css +102 -102
  90. package/src/components/nav/react.tsx +4 -4
  91. package/src/components/orbit-menu/README.md +46 -0
  92. package/src/components/orbit-menu/element.ts +27 -0
  93. package/src/components/orbit-menu/engine.ts +179 -0
  94. package/src/components/orbit-menu/orbit-menu.css +129 -0
  95. package/src/components/orbit-menu/react.tsx +44 -0
  96. package/src/components/pagination/README.md +1 -1
  97. package/src/components/pagination/engine.ts +50 -4
  98. package/src/components/pagination/pagination.css +10 -5
  99. package/src/components/pie-chart/README.md +48 -0
  100. package/src/components/pie-chart/element.ts +30 -0
  101. package/src/components/pie-chart/engine.ts +283 -0
  102. package/src/components/pie-chart/pie-chart.css +92 -0
  103. package/src/components/pie-chart/react.tsx +14 -0
  104. package/src/components/pill-button/README.md +46 -0
  105. package/src/components/pill-button/element.ts +16 -0
  106. package/src/components/pill-button/engine.ts +77 -0
  107. package/src/components/pill-button/pill-button.css +134 -0
  108. package/src/components/pill-button/react.tsx +32 -0
  109. package/src/components/radar-chart/README.md +48 -0
  110. package/src/components/radar-chart/element.ts +30 -0
  111. package/src/components/radar-chart/engine.ts +327 -0
  112. package/src/components/radar-chart/radar-chart.css +72 -0
  113. package/src/components/radar-chart/react.tsx +14 -0
  114. package/src/components/radial-chart/README.md +58 -0
  115. package/src/components/radial-chart/element.ts +30 -0
  116. package/src/components/radial-chart/engine.ts +317 -0
  117. package/src/components/radial-chart/radial-chart.css +107 -0
  118. package/src/components/radial-chart/react.tsx +14 -0
  119. package/src/components/sankey-chart/README.md +48 -0
  120. package/src/components/sankey-chart/element.ts +31 -0
  121. package/src/components/sankey-chart/engine.ts +401 -0
  122. package/src/components/sankey-chart/react.tsx +14 -0
  123. package/src/components/sankey-chart/sankey-chart.css +60 -0
  124. package/src/components/scroll-island/README.md +44 -0
  125. package/src/components/scroll-island/element.ts +16 -0
  126. package/src/components/scroll-island/engine.ts +274 -0
  127. package/src/components/scroll-island/react.tsx +26 -0
  128. package/src/components/scroll-island/scroll-island.css +113 -0
  129. package/src/components/select/README.md +34 -0
  130. package/src/components/select/element.ts +26 -0
  131. package/src/components/select/engine.ts +267 -0
  132. package/src/components/select/react.tsx +33 -0
  133. package/src/components/select/select.css +135 -0
  134. package/src/components/toolbar/README.md +46 -0
  135. package/src/components/toolbar/element.ts +30 -0
  136. package/src/components/toolbar/engine.ts +230 -0
  137. package/src/components/toolbar/react.tsx +34 -0
  138. package/src/components/toolbar/toolbar.css +121 -0
  139. package/src/components/tree-view/element.ts +1 -1
  140. package/src/components/tree-view/engine.ts +174 -0
  141. package/src/components/tree-view/react.tsx +7 -3
  142. package/src/components/tree-view/tree-view.css +79 -0
  143. package/src/components/voice-dots/README.md +48 -0
  144. package/src/components/voice-dots/element.ts +45 -0
  145. package/src/components/voice-dots/engine.ts +243 -0
  146. package/src/components/voice-dots/react.tsx +52 -0
  147. package/src/components/voice-dots/voice-dots.css +69 -0
  148. package/src/elements/index.ts +39 -0
  149. package/src/index.ts +16 -0
  150. package/src/motion/liquid.ts +14 -4
  151. package/src/react/index.ts +14 -1
  152. package/dist/chunk-6P4P7NU6.js +0 -1401
  153. package/dist/chunk-6P4P7NU6.js.map +0 -1
  154. package/dist/chunk-CU6SXW3E.js.map +0 -1
  155. package/dist/store-DjkTBX7f.d.ts +0 -195
  156. /package/dist/{chunk-EY4BJKC4.js.map → chunk-NFZIX3IA.js.map} +0 -0
@@ -0,0 +1,134 @@
1
+ /* pill-button — a dark pill whose fill gathers into a circle around the icon on hover (and spreads back on leave).
2
+ The fill is one layer clipped with inset(); the label and icon sit above it. `expand` starts as the circle. */
3
+ .mk-pill-button:is(button, a),
4
+ [data-mk-pill] {
5
+ --mk-pill-height: 3rem;
6
+ --mk-pill-dot: 2.25rem;
7
+ --mk-pill-ink: var(--mk-text);
8
+ --mk-pill-on-ink: var(--mk-bg);
9
+ --mk-pill-gap: calc((var(--mk-pill-height) - var(--mk-pill-dot)) / 2);
10
+ --mk-pill-gathered: inset(var(--mk-pill-gap) var(--mk-pill-gap) var(--mk-pill-gap) calc(100% - var(--mk-pill-gap) - var(--mk-pill-dot)) round var(--mk-radius-full));
11
+ --mk-pill-spread: inset(0 0 0 0 round var(--mk-radius-full));
12
+ position: relative;
13
+ isolation: isolate;
14
+ display: inline-flex;
15
+ align-items: center;
16
+ gap: 1.25rem;
17
+ height: var(--mk-pill-height);
18
+ padding: 0 calc(var(--mk-pill-gap) + (var(--mk-pill-dot) - 1.125rem) / 2) 0 1.5rem;
19
+ border: 0;
20
+ border-radius: var(--mk-radius-full);
21
+ background: transparent;
22
+ color: var(--mk-pill-on-ink);
23
+ font: 500 1rem/1 var(--mk-font);
24
+ text-decoration: none;
25
+ white-space: nowrap;
26
+ cursor: pointer;
27
+ -webkit-tap-highlight-color: transparent;
28
+ }
29
+
30
+ mk-pill-button {
31
+ display: inline-flex;
32
+ }
33
+
34
+ [data-mk-pill-fill] {
35
+ position: absolute;
36
+ inset: 0;
37
+ z-index: -1;
38
+ border-radius: inherit;
39
+ background: var(--mk-pill-ink);
40
+ clip-path: var(--mk-pill-spread);
41
+ transition: clip-path 0.5s var(--mk-ease-swatch);
42
+ }
43
+
44
+ [data-mk-pill-label] {
45
+ display: inline-block;
46
+ color: var(--mk-pill-on-ink);
47
+ transition:
48
+ transform 0.45s var(--mk-ease-swatch),
49
+ color 0.2s ease 0.05s;
50
+ }
51
+
52
+ [data-mk-pill-icon] {
53
+ display: inline-grid;
54
+ place-items: center;
55
+ width: 1.125rem;
56
+ height: 1.125rem;
57
+ color: var(--mk-pill-on-ink);
58
+ transition: transform 0.45s var(--mk-ease-swatch);
59
+ }
60
+
61
+ [data-mk-pill-icon] svg {
62
+ display: block;
63
+ width: 100%;
64
+ height: 100%;
65
+ }
66
+
67
+ /* Gathered: on hover, keyboard focus or a tap. */
68
+ @media (hover: hover) {
69
+ :is([data-mk-pill=''], .mk-pill-button:is(button, a):not([data-mk-pill])):hover [data-mk-pill-fill] {
70
+ clip-path: var(--mk-pill-gathered);
71
+ }
72
+ :is([data-mk-pill=''], .mk-pill-button:is(button, a):not([data-mk-pill])):hover [data-mk-pill-label] {
73
+ color: var(--mk-pill-ink);
74
+ transform: translateX(-0.5rem);
75
+ }
76
+ :is([data-mk-pill], .mk-pill-button:is(button, a)):hover [data-mk-pill-icon] {
77
+ transform: translateX(2px);
78
+ }
79
+ }
80
+ [data-mk-pill='']:is(:focus-visible, [data-mk-active]) [data-mk-pill-fill] {
81
+ clip-path: var(--mk-pill-gathered);
82
+ }
83
+ [data-mk-pill='']:is(:focus-visible, [data-mk-active]) [data-mk-pill-label] {
84
+ color: var(--mk-pill-ink);
85
+ transform: translateX(-0.5rem);
86
+ }
87
+
88
+ /* expand: the circle spreads into the pill. */
89
+ [data-mk-pill='expand'] [data-mk-pill-fill] {
90
+ clip-path: var(--mk-pill-gathered);
91
+ }
92
+ [data-mk-pill='expand'] [data-mk-pill-label] {
93
+ color: var(--mk-pill-ink);
94
+ }
95
+ @media (hover: hover) {
96
+ [data-mk-pill='expand']:hover [data-mk-pill-fill] {
97
+ clip-path: var(--mk-pill-spread);
98
+ }
99
+ [data-mk-pill='expand']:hover [data-mk-pill-label] {
100
+ color: var(--mk-pill-on-ink);
101
+ transform: translateX(0.25rem);
102
+ }
103
+ }
104
+ [data-mk-pill='expand']:is(:focus-visible, [data-mk-active]) [data-mk-pill-fill] {
105
+ clip-path: var(--mk-pill-spread);
106
+ }
107
+ [data-mk-pill='expand']:is(:focus-visible, [data-mk-active]) [data-mk-pill-label] {
108
+ color: var(--mk-pill-on-ink);
109
+ transform: translateX(0.25rem);
110
+ }
111
+
112
+ :is([data-mk-pill], .mk-pill-button:is(button, a)):focus-visible {
113
+ outline: 2px solid var(--mk-accent);
114
+ outline-offset: 3px;
115
+ }
116
+
117
+ :is([data-mk-pill], .mk-pill-button:is(button, a)):is(:disabled, [aria-disabled='true']) {
118
+ cursor: not-allowed;
119
+ opacity: 0.5;
120
+ }
121
+
122
+ @media (prefers-reduced-motion: reduce) {
123
+ [data-mk-pill-fill],
124
+ [data-mk-pill-label],
125
+ [data-mk-pill-icon] {
126
+ transition-duration: 0.01s;
127
+ }
128
+ }
129
+
130
+ @media (forced-colors: active) {
131
+ [data-mk-pill] {
132
+ border: 1px solid ButtonText;
133
+ }
134
+ }
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+ import { useRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
3
+ import { useIsomorphicLayoutEffect } from '../../react/use-recipe';
4
+ import { createPillButton, PILL_ARROW } from './engine';
5
+
6
+ export interface PillButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
7
+ /** Start as the circle and spread into the pill on hover. */
8
+ expand?: boolean;
9
+ /** The icon (default: an arrow). */
10
+ icon?: ReactNode;
11
+ }
12
+
13
+ /** <PillButton onClick={ask}>Haz una pregunta</PillButton> */
14
+ export function PillButton({ expand, icon, className, children, type = 'button', ...props }: PillButtonProps) {
15
+ const ref = useRef<HTMLButtonElement>(null);
16
+ useIsomorphicLayoutEffect(() => {
17
+ const instance = createPillButton(ref.current, { expand });
18
+ return () => instance.destroy();
19
+ }, [expand]);
20
+ return (
21
+ <button {...props} type={type} ref={ref} className={['mk-pill-button', className].filter(Boolean).join(' ')}>
22
+ <span data-mk-pill-label="">{children}</span>
23
+ {icon ? (
24
+ <span data-mk-pill-icon="" aria-hidden="true">
25
+ {icon}
26
+ </span>
27
+ ) : (
28
+ <span data-mk-pill-icon="" aria-hidden="true" dangerouslySetInnerHTML={{ __html: PILL_ARROW }} />
29
+ )}
30
+ </button>
31
+ );
32
+ }
@@ -0,0 +1,48 @@
1
+ # radar-chart
2
+
3
+ Perfiles sobre ejes radiales con áreas rellenas, transición de datos y selección por eje.
4
+
5
+ ## Instalación
6
+
7
+ ```tsx
8
+ import '@yewdiel21/motion-kit/css';
9
+ import { RadarChart } from '@yewdiel21/motion-kit/react';
10
+ ```
11
+
12
+ ```sh
13
+ npx @yewdiel21/motion-kit add radar-chart --react --elements
14
+ ```
15
+
16
+ ## React
17
+
18
+ ```tsx
19
+ <RadarChart axes={["Diseño", "Rapidez", "Calidad"]} series={[{ name: "Norte", values: [8, 6, 9] }]} max={10} />
20
+ ```
21
+
22
+ Los callbacks del ejemplo (`ask`, `run`, `setTool`, `setVolume`) pertenecen a tu aplicación. React acepta `className`, `style` y atributos HTML. Los componentes con estado exponen `instanceRef` (excepto PillButton).
23
+
24
+ ## HTML / Astro
25
+
26
+ ```html
27
+ <mk-radar-chart axes="Diseño,Rapidez,Calidad" series='[{"name":"Norte","values":[8,6,9]}]'></mk-radar-chart>
28
+ <script type="module">
29
+ import { defineElements } from '@yewdiel21/motion-kit/elements';
30
+ defineElements(['mk-radar-chart']);
31
+ </script>
32
+ ```
33
+
34
+ En Astro usa un `<script>` procesado por Astro. En HTML directo el import requiere bundler o import map. También puedes usar `import { createRadarChart } from '@yewdiel21/motion-kit'` y llamar a la factory con el contenedor y las mismas opciones. Al desmontar, llama a `instance.destroy()`.
35
+
36
+ ## Opciones
37
+
38
+ axes: al menos 3 etiquetas. series: {name, values, color?}[]. max: automático. levels: 4. grid: polygon o circle. size: 300 px. legend: true. Los valores null son datos ausentes.
39
+
40
+ Todos los gráficos aceptan `label`, `emptyLabel` y `formatValue`. Se actualizan con `update(options)` sin desmontarse; `select(index | null)` y `selected` controlan la selección (en heatmap se usa una fecha). `onSelect` también emite `mk:select`.
41
+
42
+ Los nombres camelCase se escriben como atributos kebab-case. Los arrays y objetos se pasan como JSON. Los booleanos aceptan atributo vacío o `"false"`.
43
+
44
+ ## Estilos y accesibilidad
45
+
46
+ Variables: --mk-chart-1…8, --mk-chart-surface.
47
+
48
+ Respeta los colores del tema, foco visible y `prefers-reduced-motion`. Los gráficos incluyen una tabla accesible y selección con flechas, Home, End y Escape. Heatmap usa arriba/abajo para un día e izquierda/derecha para una semana. Toolbar usa flechas y Home/End; OrbitMenu cierra con Escape y devuelve el foco. Los controles conservan nombres accesibles; VoiceDots anuncia los estados sin leer cada fotograma.
@@ -0,0 +1,30 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createRadarChart, type RadarChartInstance, type RadarChartOptions } from './engine';
3
+
4
+ /**
5
+ * <mk-radar-chart class="mk-chart mk-radar-chart" label="Perfil" axes="Velocidad,Calidad,Precio,Soporte,Diseño"
6
+ * series='[{"name":"Ana","values":[8,6,7,9,5]}]'></mk-radar-chart>
7
+ * Attribute changes glide in place. Emits mk:select ({index, label, values}).
8
+ */
9
+ export class MkRadarChartElement extends MkElement<RadarChartInstance> {
10
+ static options: Record<string, OptionType> = { axes: 'list', series: 'json', max: 'number', levels: 'number', grid: 'string', size: 'number', label: 'string', legend: 'boolean', emptyLabel: 'string' };
11
+
12
+ protected create(options: Record<string, unknown>) {
13
+ return createRadarChart(this, options as RadarChartOptions);
14
+ }
15
+
16
+ attributeChangedCallback(_name: string, previous: string | null, next: string | null) {
17
+ if (previous === next || !this.instance) return;
18
+ this.instance.update({ legend: undefined, max: undefined, ...(this.options as RadarChartOptions) });
19
+ }
20
+
21
+ update(options: RadarChartOptions) {
22
+ this.instance?.update(options);
23
+ }
24
+
25
+ select(axis: number | null) {
26
+ this.instance?.select(axis);
27
+ }
28
+ }
29
+
30
+ export const defineRadarChart = () => defineElement('mk-radar-chart', MkRadarChartElement);
@@ -0,0 +1,327 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { inert, type Instance } from '../../core/types';
5
+ import { chartFrame, finite, formatNumber, onWidth, polar, round, slotColor, svg } from '../chart/parts';
6
+
7
+ /*
8
+ * Radar chart: profiles over several measures on radial axes, in chart's
9
+ * family. Hairline rings (polygons or circles) and spokes; each series is a
10
+ * filled shape (18 %) with a 2 px edge and its points. The shapes grow out of
11
+ * the middle on the swatch curve (.38,.49,0,1.5), 90 ms apart; new data glides
12
+ * from the drawn values. Pointing near an axis (or the arrows) lights its spoke
13
+ * as a dotted guide, pops each series' point there and shows the values in the
14
+ * tooltip; a legend pill brings its series forward and dims the rest.
15
+ */
16
+
17
+ export interface RadarSeries {
18
+ name: string;
19
+ values: Array<number | null>;
20
+ color?: string;
21
+ }
22
+
23
+ export interface RadarChartOptions {
24
+ /** The measures, clockwise from the top. */
25
+ axes?: string[];
26
+ series?: RadarSeries[];
27
+ /** The outer ring's value (default: the largest value, rounded up). */
28
+ max?: number;
29
+ /** Rings (default 4). */
30
+ levels?: number;
31
+ /** Ring shape (default 'polygon'). */
32
+ grid?: 'polygon' | 'circle';
33
+ /** Diameter in px (default 300; it shrinks with its container). */
34
+ size?: number;
35
+ label?: string;
36
+ legend?: boolean;
37
+ formatValue?: (value: number) => string;
38
+ emptyLabel?: string;
39
+ onSelect?: (axis: number | null) => void;
40
+ }
41
+
42
+ export interface RadarChartInstance extends Instance {
43
+ update(options: RadarChartOptions): void;
44
+ select(axis: number | null): void;
45
+ readonly selected: number | null;
46
+ }
47
+
48
+ function niceMax(value: number) {
49
+ if (value <= 0) return 1;
50
+ const power = 10 ** Math.floor(Math.log10(value));
51
+ const fraction = value / power;
52
+ return (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 2.5 ? 2.5 : fraction <= 5 ? 5 : 10) * power;
53
+ }
54
+
55
+ export function createRadarChart(root: HTMLElement | null, initial: RadarChartOptions = {}): RadarChartInstance {
56
+ if (!root) return inert({ update() {}, select() {}, selected: null });
57
+ registerMotion();
58
+ const scope = createScope();
59
+ const frame = chartFrame(root, scope, 'radar');
60
+ const { plot, canvas, keep } = frame;
61
+ let options: RadarChartOptions = { ...initial };
62
+ let selected: number | null = null;
63
+ let focusSeries: number | null = null;
64
+
65
+ const format = (value: number) => (options.formatValue ? options.formatValue(value) : formatNumber(value));
66
+ const modelOf = () => {
67
+ const axes = (options.axes ?? []).map(String);
68
+ const raw = options.series ?? [];
69
+ return {
70
+ axes,
71
+ series: raw.map((series, i) => ({
72
+ name: String(series.name ?? ''),
73
+ values: axes.map((_, k) => (finite(series.values[k]) ? Math.max(0, series.values[k]!) : null)),
74
+ color: series.color ?? slotColor(i, raw.length),
75
+ })),
76
+ };
77
+ };
78
+ let model = modelOf();
79
+ const hasData = () => model.axes.length >= 3 && model.series.some((series) => series.values.some(finite));
80
+ const maxOf = () => (finite(options.max) && options.max > 0 ? options.max : niceMax(Math.max(0, ...model.series.flatMap((series) => series.values.filter(finite)))));
81
+
82
+ let geometry = { size: 300, cx: 150, cy: 150, r: 110 };
83
+ const angle = (k: number) => (k / model.axes.length) * Math.PI * 2;
84
+ let shapes: Array<{ area: SVGPathElement; edge: SVGPathElement; dots: SVGCircleElement[] }> = [];
85
+ let guide: SVGLineElement | null = null;
86
+ let labels: SVGTextElement[] = [];
87
+ let shown: number[][] = [];
88
+ let glide: gsap.core.Tween | null = null;
89
+ scope.add(() => glide?.kill());
90
+
91
+ const measure = () => {
92
+ const width = Math.round(plot.clientWidth || root.clientWidth || 0);
93
+ const wanted = finite(options.size) ? options.size : 300;
94
+ const size = Math.max(180, width ? Math.min(wanted, width) : wanted);
95
+ // Room around the rings for the axis names.
96
+ const r = size / 2 - Math.min(64, Math.max(36, Math.max(...model.axes.map((axis) => axis.length)) * 3.6 + 14));
97
+ geometry = { size, cx: size / 2, cy: size / 2, r: Math.max(40, r) };
98
+ canvas.setAttribute('viewBox', `0 0 ${size} ${size}`);
99
+ canvas.setAttribute('width', String(size));
100
+ canvas.setAttribute('height', String(size));
101
+ plot.style.setProperty('--mk-radar-size', `${size}px`);
102
+ };
103
+ scope.add(() => plot.style.removeProperty('--mk-radar-size'));
104
+
105
+ const pointOf = (value: number, k: number, max: number) => polar(geometry.cx, geometry.cy, (Math.min(value, max) / max) * geometry.r, angle(k));
106
+
107
+ const build = () => {
108
+ canvas.replaceChildren();
109
+ const { cx, cy, r } = geometry;
110
+ const n = model.axes.length;
111
+ const levels = Math.max(1, Math.round(options.levels ?? 4));
112
+ const grid = svg('g', { 'data-mk-part': 'grid' });
113
+ for (let level = 1; level <= levels; level++) {
114
+ const radius = (r * level) / levels;
115
+ if (options.grid === 'circle') grid.append(svg('circle', { cx, cy, r: round(radius) }));
116
+ else {
117
+ const points = Array.from({ length: n }, (_, k) => polar(cx, cy, radius, angle(k)).map(round).join(',')).join(' ');
118
+ grid.append(svg('polygon', { points }));
119
+ }
120
+ }
121
+ for (let k = 0; k < n; k++) {
122
+ const [x, y] = polar(cx, cy, r, angle(k));
123
+ grid.append(svg('line', { x1: cx, y1: cy, x2: round(x), y2: round(y), 'data-mk-spoke': k }));
124
+ }
125
+ canvas.append(grid);
126
+ const names = svg('g', { 'data-mk-part': 'axes' });
127
+ labels = model.axes.map((axis, k) => {
128
+ const a = angle(k);
129
+ const [x, y] = polar(cx, cy, r + 14, a);
130
+ const sin = Math.sin(a);
131
+ const text = svg('text', { x: round(x), y: round(y), 'text-anchor': Math.abs(sin) < 0.2 ? 'middle' : sin > 0 ? 'start' : 'end', 'dominant-baseline': 'middle' });
132
+ text.textContent = axis;
133
+ names.append(text);
134
+ return text;
135
+ });
136
+ canvas.append(names);
137
+ guide = svg('line', { 'data-mk-part': 'guide' });
138
+ guide.style.display = 'none';
139
+ canvas.append(guide);
140
+ const series = svg('g', { 'data-mk-part': 'series' });
141
+ shapes = model.series.map((each, s) => {
142
+ const group = svg('g', { 'data-mk-series': s });
143
+ const area = svg('path', { 'data-mk-part': 'area' });
144
+ area.style.fill = each.color;
145
+ const edge = svg('path', { 'data-mk-part': 'edge' });
146
+ edge.style.stroke = each.color;
147
+ group.append(area, edge);
148
+ const dots = model.axes.map(() => {
149
+ const dot = svg('circle', { r: 3, 'data-mk-part': 'dot' });
150
+ dot.style.fill = each.color;
151
+ group.append(dot);
152
+ return dot;
153
+ });
154
+ series.append(group);
155
+ return { area, edge, dots };
156
+ });
157
+ canvas.append(series);
158
+ };
159
+
160
+ const paint = (values: number[][]) => {
161
+ shown = values;
162
+ const max = maxOf();
163
+ shapes.forEach((shape, s) => {
164
+ const list = values[s] ?? [];
165
+ const points = model.axes.map((_, k) => pointOf(list[k] ?? 0, k, max));
166
+ const d = `M${points.map(([x, y]) => `${round(x)},${round(y)}`).join('L')}Z`;
167
+ shape.area.setAttribute('d', d);
168
+ shape.edge.setAttribute('d', d);
169
+ shape.dots.forEach((dot, k) => {
170
+ dot.setAttribute('cx', String(round(points[k]![0])));
171
+ dot.setAttribute('cy', String(round(points[k]![1])));
172
+ dot.style.display = finite(model.series[s]!.values[k]) ? '' : 'none';
173
+ });
174
+ });
175
+ };
176
+
177
+ const paintSelection = (announce: boolean) => {
178
+ shapes.forEach((shape, s) => {
179
+ shape.area.parentElement!.toggleAttribute('data-mk-dim', focusSeries !== null && s !== focusSeries);
180
+ shape.dots.forEach((dot, k) => {
181
+ const on = k === selected;
182
+ if (on === dot.hasAttribute('data-mk-on')) return;
183
+ dot.toggleAttribute('data-mk-on', on);
184
+ const r = on ? 5 : 3;
185
+ if (reduced()) dot.setAttribute('r', String(r));
186
+ else keep(gsap.to(dot, { attr: { r }, duration: on ? 0.3 : 0.2, ease: on ? EASE.pop : 'power2.out' }));
187
+ });
188
+ });
189
+ labels.forEach((label, k) => label.toggleAttribute('data-mk-on', k === selected));
190
+ if (selected === null || !guide) {
191
+ if (guide) guide.style.display = 'none';
192
+ frame.tip(null);
193
+ return;
194
+ }
195
+ const k = selected;
196
+ const [x, y] = polar(geometry.cx, geometry.cy, geometry.r, angle(k));
197
+ Object.entries({ x1: geometry.cx, y1: geometry.cy, x2: round(x), y2: round(y) }).forEach(([name, value]) => guide!.setAttribute(name, String(value)));
198
+ guide.style.display = '';
199
+ const rows = model.series.map((series) => ({ name: series.name, color: series.color, value: finite(series.values[k]) ? format(series.values[k]!) : '—' }));
200
+ const max = maxOf();
201
+ const top = Math.min(...model.series.map((series) => pointOf(series.values[k] ?? 0, k, max)[1]));
202
+ frame.tip({ x: round(x), y: Math.min(round(y), top), title: model.axes[k]!, rows });
203
+ if (announce) frame.live.textContent = `${model.axes[k]}. ${rows.map((row) => `${row.name ? `${row.name}: ` : ''}${row.value}`).join('. ')}`;
204
+ };
205
+
206
+ let legendItems: HTMLLIElement[] = [];
207
+ const draw = (animate: boolean) => {
208
+ glide?.kill();
209
+ const name = options.label ?? 'Gráfica de radar';
210
+ canvas.setAttribute('aria-label', hasData() ? `${name}. ${model.axes.length} medidas: ${model.axes.join(', ')}. Series: ${model.series.map((series) => series.name).join(', ')}. Usa las flechas para recorrer las medidas.` : `${name}: ${options.emptyLabel ?? 'sin datos'}`);
211
+ frame.fillTable(name, ['Medida', ...model.series.map((series) => series.name || 'Valor')], model.axes.map((axis, k) => [axis, ...model.series.map((series) => (finite(series.values[k]) ? format(series.values[k]!) : '—'))]));
212
+ legendItems = frame.fillLegend(model.series, options.legend ?? model.series.length > 1, 'area');
213
+ legendItems.forEach((item, s) => {
214
+ item.addEventListener('pointerenter', () => {
215
+ focusSeries = s;
216
+ paintSelection(false);
217
+ });
218
+ item.addEventListener('pointerleave', () => {
219
+ focusSeries = null;
220
+ paintSelection(false);
221
+ });
222
+ });
223
+ const ok = hasData();
224
+ frame.empty.hidden = ok;
225
+ frame.empty.textContent = options.emptyLabel ?? 'Sin datos para mostrar';
226
+ plot.hidden = !ok;
227
+ if (!ok) {
228
+ canvas.replaceChildren();
229
+ shapes = [];
230
+ shown = [];
231
+ selected = null;
232
+ return;
233
+ }
234
+ if (selected !== null && selected >= model.axes.length) selected = null;
235
+ const before = shown;
236
+ const comparable = before.length === model.series.length && before.every((list) => list.length === model.axes.length) && shapes.length === model.series.length;
237
+ measure();
238
+ build();
239
+ const target = model.series.map((series) => series.values.map((value) => value ?? 0));
240
+ const done = () => {
241
+ paint(target);
242
+ paintSelection(false);
243
+ };
244
+ if (!animate || reduced()) return done();
245
+ const state = { t: 0 };
246
+ if (comparable) {
247
+ glide = gsap.to(state, { t: 1, duration: 0.6, ease: EASE.swatch, onUpdate: () => paint(target.map((list, s) => list.map((value, k) => before[s]![k]! + (value - before[s]![k]!) * state.t))), onComplete: done });
248
+ return;
249
+ }
250
+ // First appearance: each shape grows out of the middle, one after the other.
251
+ const stagger = 0.09;
252
+ const total = 0.7 + stagger * (target.length - 1);
253
+ paint(target.map((list) => list.map(() => 0)));
254
+ glide = gsap.to(state, {
255
+ t: total,
256
+ duration: total,
257
+ ease: 'none',
258
+ onUpdate: () => paint(target.map((list, s) => {
259
+ const local = Math.min(1, Math.max(0, (state.t - s * stagger) / 0.7));
260
+ const eased = gsap.parseEase(EASE.swatch)(local);
261
+ return list.map((value) => value * eased);
262
+ })),
263
+ onComplete: done,
264
+ });
265
+ };
266
+
267
+ const select = (axis: number | null, announce = false) => {
268
+ if (scope.disposed) return;
269
+ const n = model.axes.length;
270
+ const next = axis === null || !hasData() ? null : ((Math.round(axis) % n) + n) % n;
271
+ if (next === selected) return;
272
+ selected = next;
273
+ paintSelection(announce);
274
+ options.onSelect?.(selected);
275
+ root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, detail: { index: selected, label: selected === null ? null : model.axes[selected], values: selected === null ? [] : model.series.map((series) => series.values[selected!]) } }));
276
+ };
277
+
278
+ /** The axis nearest the pointer's angle around the middle (none right in the middle). */
279
+ const axisAt = (event: PointerEvent) => {
280
+ const box = canvas.getBoundingClientRect();
281
+ const scale = box.width ? geometry.size / box.width : 1;
282
+ const x = (event.clientX - box.left) * scale - geometry.cx;
283
+ const y = (event.clientY - box.top) * scale - geometry.cy;
284
+ if (Math.hypot(x, y) < geometry.r * 0.12) return selected;
285
+ const a = (Math.atan2(x, -y) + Math.PI * 2) % (Math.PI * 2);
286
+ return Math.round(a / ((Math.PI * 2) / model.axes.length)) % model.axes.length;
287
+ };
288
+ scope.on(canvas, 'pointermove', (event) => select(axisAt(event as PointerEvent)));
289
+ scope.on(canvas, 'pointerdown', (event) => select(axisAt(event as PointerEvent)));
290
+ scope.on(canvas, 'pointerleave', (event) => {
291
+ if ((event as PointerEvent).pointerType === 'mouse') select(null);
292
+ });
293
+ scope.on(canvas, 'focus', () => {
294
+ if (selected === null && hasData()) select(0, true);
295
+ });
296
+ scope.on(canvas, 'blur', () => select(null));
297
+ scope.on(canvas, 'keydown', (event) => {
298
+ const key = (event as KeyboardEvent).key;
299
+ const moves: Record<string, number | null> = { ArrowRight: (selected ?? -1) + 1, ArrowDown: (selected ?? -1) + 1, ArrowLeft: (selected ?? 1) - 1, ArrowUp: (selected ?? 1) - 1, Home: 0, End: model.axes.length - 1, Escape: null };
300
+ if (!(key in moves) || !hasData()) return;
301
+ event.preventDefault();
302
+ select(moves[key]!, true);
303
+ });
304
+ onWidth(root, scope, () => {
305
+ if (!hasData()) return;
306
+ measure();
307
+ build();
308
+ paint(shown);
309
+ paintSelection(false);
310
+ });
311
+
312
+ draw(true);
313
+
314
+ return {
315
+ update(next) {
316
+ if (scope.disposed) return;
317
+ options = { ...options, ...next };
318
+ model = modelOf();
319
+ draw(true);
320
+ },
321
+ select: (axis) => select(axis),
322
+ get selected() {
323
+ return selected;
324
+ },
325
+ destroy: () => scope.dispose(),
326
+ };
327
+ }
@@ -0,0 +1,72 @@
1
+ /* radar-chart — hairline rings and spokes, filled shapes (18 %) with a 2 px edge; needs chart.css for the family.
2
+ The axis under the pointer gets chart's dotted guide and a bolder name. */
3
+ .mk-radar-chart,
4
+ mk-radar-chart {
5
+ display: grid;
6
+ justify-items: center;
7
+ gap: 1rem;
8
+ }
9
+
10
+ [data-mk-chart-type='radar'] [data-mk-chart-plot] {
11
+ width: 100%;
12
+ }
13
+
14
+ [data-mk-chart-type='radar'] [data-mk-chart-svg] {
15
+ width: var(--mk-radar-size, 300px);
16
+ max-width: 100%;
17
+ margin-inline: auto;
18
+ cursor: crosshair;
19
+ }
20
+
21
+ [data-mk-chart-type='radar'] [data-mk-part='grid'] :is(polygon, circle) {
22
+ fill: none;
23
+ stroke: var(--mk-chart-grid);
24
+ stroke-width: 1;
25
+ }
26
+
27
+ [data-mk-chart-type='radar'] [data-mk-part='grid'] line {
28
+ shape-rendering: auto;
29
+ }
30
+
31
+ [data-mk-chart-type='radar'] [data-mk-part='axes'] text {
32
+ font-size: 11px;
33
+ transition: fill 0.2s ease;
34
+ }
35
+
36
+ [data-mk-chart-type='radar'] [data-mk-part='axes'] text[data-mk-on] {
37
+ fill: var(--mk-text);
38
+ font-weight: 600;
39
+ }
40
+
41
+ [data-mk-chart-type='radar'] [data-mk-part='area'] {
42
+ fill-opacity: 0.18;
43
+ }
44
+
45
+ [data-mk-chart-type='radar'] [data-mk-part='edge'] {
46
+ fill: none;
47
+ stroke-width: 2;
48
+ stroke-linejoin: round;
49
+ }
50
+
51
+ [data-mk-chart-type='radar'] [data-mk-part='dot'] {
52
+ stroke: var(--mk-chart-surface);
53
+ stroke-width: 1.5;
54
+ }
55
+
56
+ [data-mk-chart-type='radar'] [data-mk-series] {
57
+ transition: opacity 0.2s ease;
58
+ }
59
+
60
+ [data-mk-chart-type='radar'] [data-mk-series][data-mk-dim] {
61
+ opacity: 0.2;
62
+ }
63
+
64
+ [data-mk-chart-type='radar'] [data-mk-chart-legend] {
65
+ justify-content: center;
66
+ }
67
+
68
+ @media (forced-colors: active) {
69
+ [data-mk-chart-type='radar'] [data-mk-part='edge'] {
70
+ stroke: CanvasText !important;
71
+ }
72
+ }
@@ -0,0 +1,14 @@
1
+ 'use client';
2
+ import type { HTMLAttributes } from 'react';
3
+ import { useChart } from '../chart/use-chart';
4
+ import { createRadarChart, type RadarChartInstance, type RadarChartOptions } from './engine';
5
+
6
+ export interface RadarChartProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect' | 'children'>, RadarChartOptions {
7
+ instanceRef?: { current: RadarChartInstance | null };
8
+ }
9
+
10
+ /** <RadarChart label="Perfil" axes={['Velocidad', 'Calidad', …]} series={[{ name: 'Ana', values }]} /> */
11
+ export function RadarChart({ axes, series, max, levels, grid, size, label, legend, emptyLabel, formatValue, onSelect, instanceRef, className, ...props }: RadarChartProps) {
12
+ const ref = useChart(createRadarChart, { axes, series, max, levels, grid, size, label, legend, emptyLabel } as RadarChartOptions, { formatValue, onSelect }, instanceRef);
13
+ return <div {...props} ref={ref} className={['mk-chart mk-radar-chart', className].filter(Boolean).join(' ')} />;
14
+ }