@yewdiel21/motion-kit 0.14.0 → 0.15.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.
- package/README.md +174 -155
- package/bin/motion-kit.mjs +3 -3
- package/dist/auto/index.d.ts +6 -3
- package/dist/auto/index.js +8 -2
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-EY4BJKC4.js → chunk-6IRJYXJU.js} +2 -2
- package/dist/{chunk-CU6SXW3E.js → chunk-A6QQCF6V.js} +1309 -310
- package/dist/chunk-A6QQCF6V.js.map +1 -0
- package/dist/chunk-JXRS3OEA.js +3638 -0
- package/dist/chunk-JXRS3OEA.js.map +1 -0
- package/dist/css/components/add-to-cart.css +72 -0
- package/dist/css/components/bidirectional-slider.css +168 -0
- package/dist/css/components/data-table.css +121 -26
- package/dist/css/components/filter-builder.css +10 -15
- package/dist/css/components/heatmap-chart.css +391 -0
- package/dist/css/components/hover-card.css +8 -2
- package/dist/css/components/image-preview.css +90 -2
- package/dist/css/components/json-viewer.css +33 -1
- package/dist/css/components/nav.css +102 -102
- package/dist/css/components/orbit-menu.css +130 -0
- package/dist/css/components/pagination.css +10 -5
- package/dist/css/components/pie-chart.css +390 -0
- package/dist/css/components/pill-button.css +135 -0
- package/dist/css/components/radar-chart.css +370 -0
- package/dist/css/components/radial-chart.css +398 -0
- package/dist/css/components/sankey-chart.css +358 -0
- package/dist/css/components/scroll-island.css +111 -0
- package/dist/css/components/select.css +136 -0
- package/dist/css/components/toolbar.css +122 -0
- package/dist/css/components/tree-view.css +79 -0
- package/dist/css/components/voice-dots.css +70 -0
- package/dist/elements/index.d.ts +180 -7
- package/dist/elements/index.js +287 -6
- package/dist/elements/index.js.map +1 -1
- package/dist/{engine-Cn6IhE3z.d.ts → engine-5zRIssTO.d.ts} +11 -1
- package/dist/index.css +1832 -168
- package/dist/index.d.ts +4 -4
- package/dist/index.js +33 -3
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +117 -11
- package/dist/react/index.js +314 -13
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-BBUusld6.d.ts → reduced-6X_bns60.d.ts} +1 -1
- package/dist/{reveal-DEvp6U6T.d.ts → reveal-BfyKKsw4.d.ts} +97 -4
- package/dist/store-CA2Qcun_.d.ts +453 -0
- package/package.json +9 -16
- package/registry.json +1096 -948
- package/src/auto/auto-init.ts +6 -0
- package/src/components/add-to-cart/README.md +34 -0
- package/src/components/add-to-cart/add-to-cart.css +71 -0
- package/src/components/add-to-cart/element.ts +20 -0
- package/src/components/add-to-cart/engine.ts +206 -0
- package/src/components/add-to-cart/react.tsx +29 -0
- package/src/components/bidirectional-slider/README.md +46 -0
- package/src/components/bidirectional-slider/bidirectional-slider.css +167 -0
- package/src/components/bidirectional-slider/element.ts +42 -0
- package/src/components/bidirectional-slider/engine.ts +289 -0
- package/src/components/bidirectional-slider/react.tsx +45 -0
- package/src/components/chart/README.md +10 -0
- package/src/components/chart/element.ts +2 -0
- package/src/components/chart/engine.ts +50 -23
- package/src/components/chart/parts.ts +237 -0
- package/src/components/chart/react.tsx +2 -2
- package/src/components/chart/use-chart.ts +55 -0
- package/src/components/data-table/README.md +3 -3
- package/src/components/data-table/data-table.css +121 -26
- package/src/components/data-table/engine.ts +81 -29
- package/src/components/editable-table/engine.ts +67 -11
- package/src/components/filter-builder/engine.ts +33 -2
- package/src/components/filter-builder/filter-builder.css +10 -15
- package/src/components/heatmap-chart/README.md +48 -0
- package/src/components/heatmap-chart/element.ts +21 -0
- package/src/components/heatmap-chart/engine.ts +281 -0
- package/src/components/heatmap-chart/heatmap-chart.css +93 -0
- package/src/components/heatmap-chart/react.tsx +14 -0
- package/src/components/hover-card/engine.ts +30 -1
- package/src/components/hover-card/hover-card.css +8 -2
- package/src/components/image-preview/element.ts +1 -1
- package/src/components/image-preview/engine.ts +95 -15
- package/src/components/image-preview/image-preview.css +90 -2
- package/src/components/image-preview/react.tsx +3 -3
- package/src/components/json-viewer/engine.ts +11 -2
- package/src/components/json-viewer/json-viewer.css +33 -1
- package/src/components/nav/README.md +12 -10
- package/src/components/nav/element.ts +5 -5
- package/src/components/nav/engine.ts +21 -14
- package/src/components/nav/nav.css +102 -102
- package/src/components/nav/react.tsx +4 -4
- package/src/components/orbit-menu/README.md +46 -0
- package/src/components/orbit-menu/element.ts +27 -0
- package/src/components/orbit-menu/engine.ts +179 -0
- package/src/components/orbit-menu/orbit-menu.css +129 -0
- package/src/components/orbit-menu/react.tsx +44 -0
- package/src/components/pagination/README.md +1 -1
- package/src/components/pagination/engine.ts +50 -4
- package/src/components/pagination/pagination.css +10 -5
- package/src/components/pie-chart/README.md +48 -0
- package/src/components/pie-chart/element.ts +30 -0
- package/src/components/pie-chart/engine.ts +283 -0
- package/src/components/pie-chart/pie-chart.css +92 -0
- package/src/components/pie-chart/react.tsx +14 -0
- package/src/components/pill-button/README.md +46 -0
- package/src/components/pill-button/element.ts +16 -0
- package/src/components/pill-button/engine.ts +77 -0
- package/src/components/pill-button/pill-button.css +134 -0
- package/src/components/pill-button/react.tsx +32 -0
- package/src/components/radar-chart/README.md +48 -0
- package/src/components/radar-chart/element.ts +30 -0
- package/src/components/radar-chart/engine.ts +327 -0
- package/src/components/radar-chart/radar-chart.css +72 -0
- package/src/components/radar-chart/react.tsx +14 -0
- package/src/components/radial-chart/README.md +48 -0
- package/src/components/radial-chart/element.ts +30 -0
- package/src/components/radial-chart/engine.ts +290 -0
- package/src/components/radial-chart/radial-chart.css +100 -0
- package/src/components/radial-chart/react.tsx +14 -0
- package/src/components/sankey-chart/README.md +48 -0
- package/src/components/sankey-chart/element.ts +31 -0
- package/src/components/sankey-chart/engine.ts +401 -0
- package/src/components/sankey-chart/react.tsx +14 -0
- package/src/components/sankey-chart/sankey-chart.css +60 -0
- package/src/components/scroll-island/README.md +34 -0
- package/src/components/scroll-island/element.ts +16 -0
- package/src/components/scroll-island/engine.ts +252 -0
- package/src/components/scroll-island/react.tsx +26 -0
- package/src/components/scroll-island/scroll-island.css +110 -0
- package/src/components/select/README.md +34 -0
- package/src/components/select/element.ts +26 -0
- package/src/components/select/engine.ts +267 -0
- package/src/components/select/react.tsx +33 -0
- package/src/components/select/select.css +135 -0
- package/src/components/toolbar/README.md +46 -0
- package/src/components/toolbar/element.ts +30 -0
- package/src/components/toolbar/engine.ts +230 -0
- package/src/components/toolbar/react.tsx +34 -0
- package/src/components/toolbar/toolbar.css +121 -0
- package/src/components/tree-view/element.ts +1 -1
- package/src/components/tree-view/engine.ts +174 -0
- package/src/components/tree-view/react.tsx +7 -3
- package/src/components/tree-view/tree-view.css +79 -0
- package/src/components/voice-dots/README.md +48 -0
- package/src/components/voice-dots/element.ts +45 -0
- package/src/components/voice-dots/engine.ts +243 -0
- package/src/components/voice-dots/react.tsx +52 -0
- package/src/components/voice-dots/voice-dots.css +69 -0
- package/src/elements/index.ts +39 -0
- package/src/index.ts +16 -0
- package/src/motion/liquid.ts +14 -4
- package/src/react/index.ts +14 -1
- package/dist/chunk-6P4P7NU6.js +0 -1401
- package/dist/chunk-6P4P7NU6.js.map +0 -1
- package/dist/chunk-CU6SXW3E.js.map +0 -1
- package/dist/store-DjkTBX7f.d.ts +0 -195
- /package/dist/{chunk-EY4BJKC4.js.map → chunk-6IRJYXJU.js.map} +0 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# radial-chart
|
|
2
|
+
|
|
3
|
+
Totales en anillos concéntricos o medidor con extremos redondeados y avance animado.
|
|
4
|
+
|
|
5
|
+
## Instalación
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import '@yewdiel21/motion-kit/css';
|
|
9
|
+
import { RadialChart } from '@yewdiel21/motion-kit/react';
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npx @yewdiel21/motion-kit add radial-chart --react --elements
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## React
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
<RadialChart data={[{ label: "Crear", value: 72, max: 100 }, { label: "Aprender", value: 56, max: 100 }]} sweep={270} />
|
|
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-radial-chart max="100" data='[{"label":"Crear","value":72},{"label":"Aprender","value":56}]'></mk-radial-chart>
|
|
28
|
+
<script type="module">
|
|
29
|
+
import { defineElements } from '@yewdiel21/motion-kit/elements';
|
|
30
|
+
defineElements(['mk-radial-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 { createRadialChart } 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
|
+
data: {label, value, max?, color?}[]. max: mayor valor si no se indica una meta. sweep: 360 grados (270 para gauge). size: 240 px. thickness: automático hasta 16 px. totalLabel: Total. legend: true.
|
|
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 { createRadialChart, type RadialChartInstance, type RadialChartOptions } from './engine';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <mk-radial-chart class="mk-chart mk-radial-chart" label="Metas del día"
|
|
6
|
+
* data='[{"label":"Pasos","value":7400,"max":10000},{"label":"Agua","value":5,"max":8}]'></mk-radial-chart>
|
|
7
|
+
* sweep="270" draws a gauge. Attribute changes glide in place. Emits mk:select ({index, label, value}).
|
|
8
|
+
*/
|
|
9
|
+
export class MkRadialChartElement extends MkElement<RadialChartInstance> {
|
|
10
|
+
static options: Record<string, OptionType> = { data: 'json', max: 'number', sweep: 'number', size: 'number', thickness: 'number', label: 'string', totalLabel: 'string', legend: 'boolean', emptyLabel: 'string' };
|
|
11
|
+
|
|
12
|
+
protected create(options: Record<string, unknown>) {
|
|
13
|
+
return createRadialChart(this, options as RadialChartOptions);
|
|
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 RadialChartOptions) });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
update(options: RadialChartOptions) {
|
|
22
|
+
this.instance?.update(options);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
select(index: number | null) {
|
|
26
|
+
this.instance?.select(index);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const defineRadialChart = () => defineElement('mk-radial-chart', MkRadialChartElement);
|
|
@@ -0,0 +1,290 @@
|
|
|
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 { blurIn, rollDigits } from '../../motion/micro';
|
|
6
|
+
import { chartFrame, finite, formatNumber, onWidth, polar, round, slotColor, svg } from '../chart/parts';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Radial chart: totals as rings, one inside the other, each filling its track
|
|
10
|
+
* from 12 o'clock with round ends (or a 270° gauge), in chart's family. The
|
|
11
|
+
* rings sweep in on the swatch curve (.38,.49,0,1.5), 80 ms apart from the
|
|
12
|
+
* outside in, while the middle rolls up to the total; new data glides each
|
|
13
|
+
* ring. Pointing at a ring (its legend pill, or the arrows) thickens it, dims
|
|
14
|
+
* the rest and the middle says its value and how much of its goal it is.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface RadialDatum {
|
|
18
|
+
label: string;
|
|
19
|
+
value: number;
|
|
20
|
+
/** This ring's goal (default: the chart's max). */
|
|
21
|
+
max?: number;
|
|
22
|
+
color?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RadialChartOptions {
|
|
26
|
+
/** Rings from the outside in. */
|
|
27
|
+
data?: RadialDatum[];
|
|
28
|
+
/** What a full ring is worth (default: the largest value). */
|
|
29
|
+
max?: number;
|
|
30
|
+
/** Degrees a full ring covers (default 360; 270 makes a gauge). */
|
|
31
|
+
sweep?: number;
|
|
32
|
+
/** Diameter in px (default 240). */
|
|
33
|
+
size?: number;
|
|
34
|
+
/** Ring thickness in px (default: what fits, up to 16). */
|
|
35
|
+
thickness?: number;
|
|
36
|
+
label?: string;
|
|
37
|
+
/** The middle when nothing is chosen (default 'Total'). */
|
|
38
|
+
totalLabel?: string;
|
|
39
|
+
legend?: boolean;
|
|
40
|
+
formatValue?: (value: number) => string;
|
|
41
|
+
emptyLabel?: string;
|
|
42
|
+
onSelect?: (index: number | null) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RadialChartInstance extends Instance {
|
|
46
|
+
update(options: RadialChartOptions): void;
|
|
47
|
+
select(index: number | null): void;
|
|
48
|
+
readonly selected: number | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A stroked arc on a circle from `a0` to `a1` (radians, 0 at the top, clockwise). */
|
|
52
|
+
function strokeArc(cx: number, cy: number, r: number, a0: number, a1: number) {
|
|
53
|
+
const end = a0 + Math.min(a1 - a0, Math.PI * 2 - 1e-3);
|
|
54
|
+
const [x0, y0] = polar(cx, cy, r, a0);
|
|
55
|
+
const [x1, y1] = polar(cx, cy, r, end);
|
|
56
|
+
return `M${round(x0)},${round(y0)}A${round(r)},${round(r)} 0 ${end - a0 > Math.PI ? 1 : 0} 1 ${round(x1)},${round(y1)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createRadialChart(root: HTMLElement | null, initial: RadialChartOptions = {}): RadialChartInstance {
|
|
60
|
+
if (!root) return inert({ update() {}, select() {}, selected: null });
|
|
61
|
+
registerMotion();
|
|
62
|
+
const scope = createScope();
|
|
63
|
+
const frame = chartFrame(root, scope, 'radial');
|
|
64
|
+
const { plot, canvas, keep } = frame;
|
|
65
|
+
let options: RadialChartOptions = { ...initial };
|
|
66
|
+
let selected: number | null = null;
|
|
67
|
+
|
|
68
|
+
const center = document.createElement('div');
|
|
69
|
+
center.dataset.mkRadialCenter = '';
|
|
70
|
+
center.setAttribute('aria-hidden', 'true');
|
|
71
|
+
const centerValue = document.createElement('strong');
|
|
72
|
+
const centerLabel = document.createElement('span');
|
|
73
|
+
center.append(centerValue, centerLabel);
|
|
74
|
+
plot.append(center);
|
|
75
|
+
scope.add(() => center.remove());
|
|
76
|
+
|
|
77
|
+
const format = (value: number) => (options.formatValue ? options.formatValue(value) : formatNumber(value));
|
|
78
|
+
const modelOf = () => {
|
|
79
|
+
const data = (options.data ?? []).filter((datum) => finite(datum.value));
|
|
80
|
+
const top = Math.max(0, ...data.map((datum) => datum.value));
|
|
81
|
+
return data.map((datum, i) => ({
|
|
82
|
+
label: String(datum.label),
|
|
83
|
+
value: Math.max(0, datum.value),
|
|
84
|
+
max: finite(datum.max) && datum.max > 0 ? datum.max : finite(options.max) && options.max > 0 ? options.max : top || 1,
|
|
85
|
+
color: datum.color ?? slotColor(i, Math.max(2, data.length)),
|
|
86
|
+
}));
|
|
87
|
+
};
|
|
88
|
+
let model = modelOf();
|
|
89
|
+
const total = () => model.reduce((sum, ring) => sum + ring.value, 0);
|
|
90
|
+
const percent = (ring: { value: number; max: number }) => new Intl.NumberFormat(undefined, { style: 'percent', maximumFractionDigits: 0 }).format(ring.value / ring.max);
|
|
91
|
+
const sweep = () => (Math.min(360, Math.max(90, finite(options.sweep) ? options.sweep : 360)) * Math.PI) / 180;
|
|
92
|
+
const startAngle = () => -sweep() / 2 + (sweep() >= Math.PI * 2 - 1e-6 ? Math.PI : 0);
|
|
93
|
+
|
|
94
|
+
let geometry = { size: 240, cx: 120, cy: 120, radii: [] as number[], thickness: 12 };
|
|
95
|
+
let arcs: SVGPathElement[] = [];
|
|
96
|
+
let tracks: SVGPathElement[] = [];
|
|
97
|
+
let shown: number[] = [];
|
|
98
|
+
let glide: gsap.core.Tween | null = null;
|
|
99
|
+
scope.add(() => glide?.kill());
|
|
100
|
+
|
|
101
|
+
const measure = () => {
|
|
102
|
+
const width = Math.round(plot.clientWidth || root.clientWidth || 0);
|
|
103
|
+
const wanted = finite(options.size) ? options.size : 240;
|
|
104
|
+
const size = Math.max(120, width ? Math.min(wanted, width) : wanted);
|
|
105
|
+
const n = Math.max(1, model.length);
|
|
106
|
+
const outer = size / 2 - 4;
|
|
107
|
+
// Rings share the outer 60 % of the radius, a quarter of a ring apart.
|
|
108
|
+
const fit = (outer * 0.6) / (n + (n - 1) * 0.35);
|
|
109
|
+
const thickness = finite(options.thickness) ? options.thickness : Math.max(4, Math.min(16, fit));
|
|
110
|
+
const spacing = thickness * 1.35;
|
|
111
|
+
geometry = { size, cx: size / 2, cy: size / 2, thickness, radii: model.map((_, i) => outer - thickness / 2 - i * spacing) };
|
|
112
|
+
canvas.setAttribute('viewBox', `0 0 ${size} ${size}`);
|
|
113
|
+
canvas.setAttribute('width', String(size));
|
|
114
|
+
canvas.setAttribute('height', String(size));
|
|
115
|
+
plot.style.setProperty('--mk-radial-size', `${size}px`);
|
|
116
|
+
plot.style.setProperty('--mk-radial-thickness', `${thickness}px`);
|
|
117
|
+
};
|
|
118
|
+
scope.add(() => {
|
|
119
|
+
plot.style.removeProperty('--mk-radial-size');
|
|
120
|
+
plot.style.removeProperty('--mk-radial-thickness');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const build = () => {
|
|
124
|
+
canvas.replaceChildren();
|
|
125
|
+
const { cx, cy, radii } = geometry;
|
|
126
|
+
const a0 = startAngle();
|
|
127
|
+
const back = svg('g', { 'data-mk-part': 'tracks' });
|
|
128
|
+
const front = svg('g', { 'data-mk-part': 'series' });
|
|
129
|
+
tracks = model.map((_, i) => {
|
|
130
|
+
const track = svg('path', { d: strokeArc(cx, cy, radii[i]!, a0, a0 + sweep()), 'data-mk-index': i });
|
|
131
|
+
back.append(track);
|
|
132
|
+
return track;
|
|
133
|
+
});
|
|
134
|
+
arcs = model.map((ring, i) => {
|
|
135
|
+
const arc = svg('path', { 'data-mk-index': i, 'data-mk-part': 'ring' });
|
|
136
|
+
arc.style.stroke = ring.color;
|
|
137
|
+
front.append(arc);
|
|
138
|
+
return arc;
|
|
139
|
+
});
|
|
140
|
+
canvas.append(back, front);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const paint = (values: number[]) => {
|
|
144
|
+
shown = values;
|
|
145
|
+
const { cx, cy, radii } = geometry;
|
|
146
|
+
const a0 = startAngle();
|
|
147
|
+
arcs.forEach((arc, i) => {
|
|
148
|
+
const share = Math.min(1, Math.max(0, (values[i] ?? 0) / model[i]!.max));
|
|
149
|
+
arc.setAttribute('d', share > 0.001 ? strokeArc(cx, cy, radii[i]!, a0, a0 + sweep() * share) : '');
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const paintCenter = (animate: boolean) => {
|
|
154
|
+
center.hidden = !model.length;
|
|
155
|
+
const ring = selected === null ? null : model[selected];
|
|
156
|
+
const text = ring ? format(ring.value) : format(total());
|
|
157
|
+
const caption = ring ? `${ring.label} · ${percent(ring)}` : (options.totalLabel ?? 'Total');
|
|
158
|
+
const changed = centerLabel.textContent !== caption;
|
|
159
|
+
if (centerValue.textContent !== text) {
|
|
160
|
+
if (animate && !reduced() && centerValue.textContent) keep(rollDigits(centerValue, text, 1, 0.35));
|
|
161
|
+
else centerValue.textContent = text;
|
|
162
|
+
}
|
|
163
|
+
centerLabel.textContent = caption;
|
|
164
|
+
if (changed && animate && !reduced()) keep(blurIn(centerLabel, { from: 'soft', duration: 0.3 }));
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
let legendItems: HTMLLIElement[] = [];
|
|
168
|
+
const paintSelection = (announce: boolean) => {
|
|
169
|
+
for (const list of [arcs, tracks]) list.forEach((each, i) => {
|
|
170
|
+
each.toggleAttribute('data-mk-on', i === selected);
|
|
171
|
+
each.toggleAttribute('data-mk-dim', selected !== null && i !== selected);
|
|
172
|
+
});
|
|
173
|
+
legendItems.forEach((item, i) => item.toggleAttribute('data-mk-dim', selected !== null && i !== selected));
|
|
174
|
+
paintCenter(true);
|
|
175
|
+
const ring = selected === null ? null : model[selected];
|
|
176
|
+
if (announce && ring) frame.live.textContent = `${ring.label}: ${format(ring.value)} de ${format(ring.max)}, ${percent(ring)}`;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const draw = (animate: boolean) => {
|
|
180
|
+
glide?.kill();
|
|
181
|
+
const name = options.label ?? 'Gráfica radial';
|
|
182
|
+
canvas.setAttribute('aria-label', model.length ? `${name}. ${model.length} anillos, total ${format(total())}. Usa las flechas para recorrerlos.` : `${name}: ${options.emptyLabel ?? 'sin datos'}`);
|
|
183
|
+
frame.fillTable(name, ['Anillo', 'Valor', 'Meta', 'Avance'], model.map((ring) => [ring.label, format(ring.value), format(ring.max), percent(ring)]));
|
|
184
|
+
legendItems = frame.fillLegend(model.map((datum) => ({ name: datum.label, color: datum.color })), options.legend ?? true);
|
|
185
|
+
legendItems.forEach((item, i) => {
|
|
186
|
+
item.addEventListener('pointerenter', () => select(i));
|
|
187
|
+
item.addEventListener('pointerleave', () => select(null));
|
|
188
|
+
});
|
|
189
|
+
frame.empty.hidden = model.length > 0;
|
|
190
|
+
frame.empty.textContent = options.emptyLabel ?? 'Sin datos para mostrar';
|
|
191
|
+
plot.hidden = !model.length;
|
|
192
|
+
if (!model.length) {
|
|
193
|
+
canvas.replaceChildren();
|
|
194
|
+
arcs = [];
|
|
195
|
+
shown = [];
|
|
196
|
+
selected = null;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (selected !== null && selected >= model.length) selected = null;
|
|
200
|
+
const before = shown;
|
|
201
|
+
const comparable = before.length === model.length && arcs.length === model.length;
|
|
202
|
+
measure();
|
|
203
|
+
build();
|
|
204
|
+
const target = model.map((ring) => ring.value);
|
|
205
|
+
const done = () => {
|
|
206
|
+
paint(target);
|
|
207
|
+
paintSelection(false);
|
|
208
|
+
};
|
|
209
|
+
if (!animate || reduced()) return done();
|
|
210
|
+
const state = { t: 0 };
|
|
211
|
+
if (comparable) {
|
|
212
|
+
glide = gsap.to(state, { t: 1, duration: 0.6, ease: EASE.swatch, onUpdate: () => paint(target.map((value, i) => before[i]! + (value - before[i]!) * state.t)), onComplete: done });
|
|
213
|
+
paintCenter(true);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// First appearance: the rings sweep in from the outside in, 80 ms apart, and the total rolls up.
|
|
217
|
+
const stagger = 0.08;
|
|
218
|
+
const length = 0.8 + stagger * (target.length - 1);
|
|
219
|
+
const ease = gsap.parseEase(EASE.swatch);
|
|
220
|
+
paint(target.map(() => 0));
|
|
221
|
+
centerValue.textContent = format(0);
|
|
222
|
+
glide = gsap.to(state, {
|
|
223
|
+
t: length,
|
|
224
|
+
duration: length,
|
|
225
|
+
ease: 'none',
|
|
226
|
+
onUpdate: () => paint(target.map((value, i) => value * ease(Math.min(1, Math.max(0, (state.t - i * stagger) / 0.8))))),
|
|
227
|
+
onComplete: done,
|
|
228
|
+
});
|
|
229
|
+
paintCenter(true);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const select = (index: number | null, announce = false) => {
|
|
233
|
+
if (scope.disposed) return;
|
|
234
|
+
const next = index === null || !model.length ? null : ((Math.round(index) % model.length) + model.length) % model.length;
|
|
235
|
+
if (next === selected) return;
|
|
236
|
+
selected = next;
|
|
237
|
+
paintSelection(announce);
|
|
238
|
+
options.onSelect?.(selected);
|
|
239
|
+
const ring = selected === null ? null : model[selected];
|
|
240
|
+
root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, detail: { index: selected, label: ring?.label ?? null, value: ring?.value ?? null } }));
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/** The ring under the pointer (by its distance from the middle). */
|
|
244
|
+
const ringAt = (event: PointerEvent) => {
|
|
245
|
+
const box = canvas.getBoundingClientRect();
|
|
246
|
+
const scale = box.width ? geometry.size / box.width : 1;
|
|
247
|
+
const distance = Math.hypot((event.clientX - box.left) * scale - geometry.cx, (event.clientY - box.top) * scale - geometry.cy);
|
|
248
|
+
const near = geometry.radii.findIndex((r) => Math.abs(distance - r) <= geometry.thickness * 0.7);
|
|
249
|
+
return near < 0 ? null : near;
|
|
250
|
+
};
|
|
251
|
+
scope.on(canvas, 'pointermove', (event) => select(ringAt(event as PointerEvent)));
|
|
252
|
+
scope.on(canvas, 'pointerdown', (event) => select(ringAt(event as PointerEvent)));
|
|
253
|
+
scope.on(canvas, 'pointerleave', (event) => {
|
|
254
|
+
if ((event as PointerEvent).pointerType === 'mouse') select(null);
|
|
255
|
+
});
|
|
256
|
+
scope.on(canvas, 'focus', () => {
|
|
257
|
+
if (selected === null && model.length) select(0, true);
|
|
258
|
+
});
|
|
259
|
+
scope.on(canvas, 'blur', () => select(null));
|
|
260
|
+
scope.on(canvas, 'keydown', (event) => {
|
|
261
|
+
const key = (event as KeyboardEvent).key;
|
|
262
|
+
const moves: Record<string, number | null> = { ArrowDown: (selected ?? -1) + 1, ArrowRight: (selected ?? -1) + 1, ArrowUp: (selected ?? 1) - 1, ArrowLeft: (selected ?? 1) - 1, Home: 0, End: model.length - 1, Escape: null };
|
|
263
|
+
if (!(key in moves) || !model.length) return;
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
select(moves[key]!, true);
|
|
266
|
+
});
|
|
267
|
+
onWidth(root, scope, () => {
|
|
268
|
+
if (!model.length) return;
|
|
269
|
+
measure();
|
|
270
|
+
build();
|
|
271
|
+
paint(shown);
|
|
272
|
+
paintSelection(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
draw(true);
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
update(next) {
|
|
279
|
+
if (scope.disposed) return;
|
|
280
|
+
options = { ...options, ...next };
|
|
281
|
+
model = modelOf();
|
|
282
|
+
draw(true);
|
|
283
|
+
},
|
|
284
|
+
select: (index) => select(index),
|
|
285
|
+
get selected() {
|
|
286
|
+
return selected;
|
|
287
|
+
},
|
|
288
|
+
destroy: () => scope.dispose(),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/* radial-chart — rings with round ends over soft tracks; needs chart.css for the family.
|
|
2
|
+
The chosen ring thickens, the others dim, and the middle names it. */
|
|
3
|
+
.mk-radial-chart,
|
|
4
|
+
mk-radial-chart {
|
|
5
|
+
display: grid;
|
|
6
|
+
justify-items: center;
|
|
7
|
+
gap: 1rem;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
[data-mk-chart-type='radial'] [data-mk-chart-plot] {
|
|
11
|
+
width: 100%;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
[data-mk-chart-type='radial'] [data-mk-chart-svg] {
|
|
15
|
+
width: var(--mk-radial-size, 240px);
|
|
16
|
+
max-width: 100%;
|
|
17
|
+
margin-inline: auto;
|
|
18
|
+
border-radius: 50%;
|
|
19
|
+
cursor: pointer;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
[data-mk-chart-type='radial'] :is([data-mk-part='tracks'], [data-mk-part='series']) path {
|
|
23
|
+
fill: none;
|
|
24
|
+
stroke-linecap: round;
|
|
25
|
+
stroke-width: var(--mk-radial-thickness, 12px);
|
|
26
|
+
transition:
|
|
27
|
+
stroke-width 0.35s var(--mk-ease-swatch),
|
|
28
|
+
opacity 0.2s ease;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
[data-mk-chart-type='radial'] [data-mk-part='tracks'] path {
|
|
32
|
+
stroke: var(--mk-chart-grid);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
[data-mk-chart-type='radial'] :is([data-mk-part='tracks'], [data-mk-part='series']) path[data-mk-on] {
|
|
36
|
+
stroke-width: calc(var(--mk-radial-thickness, 12px) + 4px);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
[data-mk-chart-type='radial'] :is([data-mk-part='tracks'], [data-mk-part='series']) path[data-mk-dim] {
|
|
40
|
+
opacity: 0.35;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
[data-mk-radial-center] {
|
|
44
|
+
position: absolute;
|
|
45
|
+
top: 50%;
|
|
46
|
+
left: 50%;
|
|
47
|
+
display: grid;
|
|
48
|
+
justify-items: center;
|
|
49
|
+
gap: 0.15rem;
|
|
50
|
+
max-width: calc(var(--mk-radial-size, 240px) * 0.36);
|
|
51
|
+
transform: translate(-50%, -50%);
|
|
52
|
+
text-align: center;
|
|
53
|
+
pointer-events: none;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
[data-mk-radial-center][hidden] {
|
|
57
|
+
display: none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
[data-mk-radial-center] strong {
|
|
61
|
+
color: var(--mk-text);
|
|
62
|
+
font-size: clamp(1rem, calc(var(--mk-radial-size, 240px) * 0.1), 1.75rem);
|
|
63
|
+
font-weight: 650;
|
|
64
|
+
font-variant-numeric: tabular-nums;
|
|
65
|
+
line-height: 1.1;
|
|
66
|
+
letter-spacing: -0.02em;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
[data-mk-radial-center] span {
|
|
70
|
+
overflow: hidden;
|
|
71
|
+
max-width: 100%;
|
|
72
|
+
color: var(--mk-muted);
|
|
73
|
+
font-size: 0.75rem;
|
|
74
|
+
white-space: nowrap;
|
|
75
|
+
text-overflow: ellipsis;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
[data-mk-chart-type='radial'] [data-mk-chart-legend] {
|
|
79
|
+
justify-content: center;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
[data-mk-chart-type='radial'] [data-mk-chart-legend] li {
|
|
83
|
+
transition: opacity 0.2s ease;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
[data-mk-chart-type='radial'] [data-mk-chart-legend] li[data-mk-dim] {
|
|
87
|
+
opacity: 0.45;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@media (prefers-reduced-motion: reduce) {
|
|
91
|
+
[data-mk-chart-type='radial'] :is([data-mk-part='tracks'], [data-mk-part='series']) path {
|
|
92
|
+
transition: opacity 0.2s ease;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@media (forced-colors: active) {
|
|
97
|
+
[data-mk-chart-type='radial'] [data-mk-part='series'] path {
|
|
98
|
+
stroke: Highlight !important;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import type { HTMLAttributes } from 'react';
|
|
3
|
+
import { useChart } from '../chart/use-chart';
|
|
4
|
+
import { createRadialChart, type RadialChartInstance, type RadialChartOptions } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface RadialChartProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect' | 'children'>, RadialChartOptions {
|
|
7
|
+
instanceRef?: { current: RadialChartInstance | null };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** <RadialChart label="Metas" data={[{ label: 'Pasos', value: 7400, max: 10000 }, …]} /> */
|
|
11
|
+
export function RadialChart({ data, max, sweep, size, thickness, label, totalLabel, legend, emptyLabel, formatValue, onSelect, instanceRef, className, ...props }: RadialChartProps) {
|
|
12
|
+
const ref = useChart(createRadialChart, { data, max, sweep, size, thickness, label, totalLabel, legend, emptyLabel } as RadialChartOptions, { formatValue, onSelect }, instanceRef);
|
|
13
|
+
return <div {...props} ref={ref} className={['mk-chart mk-radial-chart', className].filter(Boolean).join(' ')} />;
|
|
14
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# sankey-chart
|
|
2
|
+
|
|
3
|
+
Flujos entre etapas con enlaces proporcionales a su valor, entrada escalonada y selección de recorridos.
|
|
4
|
+
|
|
5
|
+
## Instalación
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import '@yewdiel21/motion-kit/css';
|
|
9
|
+
import { SankeyChart } from '@yewdiel21/motion-kit/react';
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npx @yewdiel21/motion-kit add sankey-chart --react --elements
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## React
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
<SankeyChart nodes={[{ id: "visitas", label: "Visitas" }, { id: "ventas", label: "Ventas" }]} links={[{ source: "visitas", target: "ventas", value: 42 }]} />
|
|
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-sankey-chart nodes='[{"id":"visitas","label":"Visitas"},{"id":"ventas","label":"Ventas"}]' links='[{"source":"visitas","target":"ventas","value":42}]'></mk-sankey-chart>
|
|
28
|
+
<script type="module">
|
|
29
|
+
import { defineElements } from '@yewdiel21/motion-kit/elements';
|
|
30
|
+
defineElements(['mk-sankey-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 { createSankeyChart } 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
|
+
nodes: {id, label?, color?}[]. links: {source, target, value}[]. height: 320 px. nodeWidth: 12 px. nodePadding: 14 px. Usa un grafo dirigido sin ciclos; se omiten enlaces inválidos, no positivos o sin nodo.
|
|
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,31 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createSankeyChart, type SankeyChartInstance, type SankeyChartOptions } from './engine';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <mk-sankey-chart class="mk-chart mk-sankey-chart" label="Embudo de ventas"
|
|
6
|
+
* nodes='[{"id":"visitas","label":"Visitas"},{"id":"carrito","label":"Carrito"}]'
|
|
7
|
+
* links='[{"source":"visitas","target":"carrito","value":420}]'></mk-sankey-chart>
|
|
8
|
+
* Attribute changes glide in place. Emits mk:select ({index, source, target, value}).
|
|
9
|
+
*/
|
|
10
|
+
export class MkSankeyChartElement extends MkElement<SankeyChartInstance> {
|
|
11
|
+
static options: Record<string, OptionType> = { nodes: 'json', links: 'json', height: 'number', nodeWidth: 'number', nodePadding: 'number', label: 'string', emptyLabel: 'string' };
|
|
12
|
+
|
|
13
|
+
protected create(options: Record<string, unknown>) {
|
|
14
|
+
return createSankeyChart(this, options as SankeyChartOptions);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
attributeChangedCallback(_name: string, previous: string | null, next: string | null) {
|
|
18
|
+
if (previous === next || !this.instance) return;
|
|
19
|
+
this.instance.update(this.options as SankeyChartOptions);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
update(options: SankeyChartOptions) {
|
|
23
|
+
this.instance?.update(options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
select(index: number | null) {
|
|
27
|
+
this.instance?.select(index);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const defineSankeyChart = () => defineElement('mk-sankey-chart', MkSankeyChartElement);
|