@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.
- package/README.md +178 -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-NFZIX3IA.js} +2 -2
- package/dist/chunk-RBEKR5UZ.js +3660 -0
- package/dist/chunk-RBEKR5UZ.js.map +1 -0
- package/dist/{chunk-CU6SXW3E.js → chunk-SDQ776LG.js} +1596 -485
- package/dist/chunk-SDQ776LG.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/chart.css +7 -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 +398 -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 +397 -0
- package/dist/css/components/pill-button.css +135 -0
- package/dist/css/components/radar-chart.css +377 -0
- package/dist/css/components/radial-chart.css +412 -0
- package/dist/css/components/sankey-chart.css +365 -0
- package/dist/css/components/scroll-island.css +114 -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 +293 -7
- package/dist/elements/index.js.map +1 -1
- package/dist/{engine-Cn6IhE3z.d.ts → engine-5zRIssTO.d.ts} +11 -1
- package/dist/index.css +1849 -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 +320 -13
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-BBUusld6.d.ts → reduced-DQwVXQYb.d.ts} +1 -1
- package/dist/{reveal-DEvp6U6T.d.ts → reveal-DRpNO4KP.d.ts} +109 -4
- package/dist/store-Da3zSh8Z.d.ts +457 -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 +49 -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 +33 -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 +49 -24
- package/src/components/chart/chart.css +7 -0
- package/src/components/chart/element.ts +4 -1
- package/src/components/chart/engine.ts +153 -34
- 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 +58 -0
- package/src/components/radial-chart/element.ts +30 -0
- package/src/components/radial-chart/engine.ts +317 -0
- package/src/components/radial-chart/radial-chart.css +107 -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 +44 -0
- package/src/components/scroll-island/element.ts +16 -0
- package/src/components/scroll-island/engine.ts +274 -0
- package/src/components/scroll-island/react.tsx +26 -0
- package/src/components/scroll-island/scroll-island.css +113 -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-NFZIX3IA.js.map} +0 -0
|
@@ -0,0 +1,401 @@
|
|
|
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, round, slotColor, svg, SVG_NS } from '../chart/parts';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Sankey chart: flows between stages as bands as wide as their value, in
|
|
9
|
+
* chart's family. Nodes stand in columns by their depth (the ones that lead
|
|
10
|
+
* nowhere end at the right), stacked in the given order and centred; each
|
|
11
|
+
* band fades from its source's colour to its target's. The nodes grow from
|
|
12
|
+
* their middles, column after column, while the bands are wiped in from the
|
|
13
|
+
* left (.9 s); new data with the same nodes and flows glides every node and
|
|
14
|
+
* band. Pointing at a band (or the arrows) lights it and dims the rest, with
|
|
15
|
+
* "A → B" and its share in the tooltip; pointing at a node lights its bands.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface SankeyNode {
|
|
19
|
+
id: string;
|
|
20
|
+
label?: string;
|
|
21
|
+
color?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SankeyLink {
|
|
25
|
+
source: string;
|
|
26
|
+
target: string;
|
|
27
|
+
value: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SankeyChartOptions {
|
|
31
|
+
nodes?: SankeyNode[];
|
|
32
|
+
links?: SankeyLink[];
|
|
33
|
+
/** Plot height in px (default 320). */
|
|
34
|
+
height?: number;
|
|
35
|
+
/** Node width in px (default 12). */
|
|
36
|
+
nodeWidth?: number;
|
|
37
|
+
/** Space between nodes in a column, px (default 14). */
|
|
38
|
+
nodePadding?: number;
|
|
39
|
+
label?: string;
|
|
40
|
+
formatValue?: (value: number) => string;
|
|
41
|
+
emptyLabel?: string;
|
|
42
|
+
/** A flow was chosen (its index in `links`), or none. */
|
|
43
|
+
onSelect?: (index: number | null) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SankeyChartInstance extends Instance {
|
|
47
|
+
update(options: SankeyChartOptions): void;
|
|
48
|
+
select(index: number | null): void;
|
|
49
|
+
readonly selected: number | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Layout {
|
|
53
|
+
nodes: Array<{ x: number; y0: number; y1: number }>;
|
|
54
|
+
links: Array<{ sy: number; ty: number; width: number }>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createSankeyChart(root: HTMLElement | null, initial: SankeyChartOptions = {}): SankeyChartInstance {
|
|
58
|
+
if (!root) return inert({ update() {}, select() {}, selected: null });
|
|
59
|
+
registerMotion();
|
|
60
|
+
const scope = createScope();
|
|
61
|
+
const frame = chartFrame(root, scope, 'sankey');
|
|
62
|
+
const { plot, canvas } = frame;
|
|
63
|
+
let options: SankeyChartOptions = { ...initial };
|
|
64
|
+
let selected: number | null = null;
|
|
65
|
+
let hoverNode: number | null = null;
|
|
66
|
+
const uid = `mk-sankey-${Math.random().toString(36).slice(2, 8)}`;
|
|
67
|
+
|
|
68
|
+
const format = (value: number) => (options.formatValue ? options.formatValue(value) : formatNumber(value));
|
|
69
|
+
const modelOf = () => {
|
|
70
|
+
const nodes = (options.nodes ?? []).map((node, i, all) => ({ id: String(node.id), label: String(node.label ?? node.id), color: node.color ?? slotColor(i, Math.max(2, all.length)) }));
|
|
71
|
+
const index = new Map(nodes.map((node, i) => [node.id, i]));
|
|
72
|
+
const links = (options.links ?? [])
|
|
73
|
+
.map((link, i) => ({ source: index.get(String(link.source)) ?? -1, target: index.get(String(link.target)) ?? -1, value: link.value, given: i }))
|
|
74
|
+
.filter((link) => link.source >= 0 && link.target >= 0 && link.source !== link.target && finite(link.value) && link.value > 0);
|
|
75
|
+
// Depth: the longest way in from a source (bounded, so a loop cannot run away).
|
|
76
|
+
const depth = nodes.map(() => 0);
|
|
77
|
+
for (let pass = 0; pass < nodes.length; pass++) {
|
|
78
|
+
let moved = false;
|
|
79
|
+
for (const link of links) {
|
|
80
|
+
if (depth[link.target]! < depth[link.source]! + 1 && depth[link.source]! + 1 < nodes.length) {
|
|
81
|
+
depth[link.target] = depth[link.source]! + 1;
|
|
82
|
+
moved = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!moved) break;
|
|
86
|
+
}
|
|
87
|
+
const columns = Math.max(0, ...depth) + 1;
|
|
88
|
+
// The ones that lead nowhere end at the right.
|
|
89
|
+
nodes.forEach((_, i) => {
|
|
90
|
+
if (links.some((link) => link.source === i) || !links.some((link) => link.target === i)) return;
|
|
91
|
+
depth[i] = columns - 1;
|
|
92
|
+
});
|
|
93
|
+
const used = nodes.map((_, i) => links.some((link) => link.source === i || link.target === i));
|
|
94
|
+
const value = nodes.map((_, i) => Math.max(links.filter((link) => link.target === i).reduce((s, l) => s + l.value, 0), links.filter((link) => link.source === i).reduce((s, l) => s + l.value, 0)));
|
|
95
|
+
return { nodes, links, depth, columns, used, value };
|
|
96
|
+
};
|
|
97
|
+
let model = modelOf();
|
|
98
|
+
const hasData = () => model.links.length > 0;
|
|
99
|
+
|
|
100
|
+
let width = 600;
|
|
101
|
+
let height = 320;
|
|
102
|
+
const layoutOf = (): Layout => {
|
|
103
|
+
width = Math.max(240, Math.round(plot.clientWidth || root.clientWidth || 600));
|
|
104
|
+
height = Math.max(160, finite(options.height) ? options.height : 320);
|
|
105
|
+
const nodeWidth = Math.max(4, finite(options.nodeWidth) ? options.nodeWidth : 12);
|
|
106
|
+
const pad = Math.max(2, finite(options.nodePadding) ? options.nodePadding : 14);
|
|
107
|
+
const top = 6;
|
|
108
|
+
const usable = height - top * 2;
|
|
109
|
+
const columns = Array.from({ length: model.columns }, (_, c) => model.nodes.map((_, i) => i).filter((i) => model.used[i] && model.depth[i] === c));
|
|
110
|
+
const ky = Math.min(...columns.filter((list) => list.length).map((list) => (usable - pad * (list.length - 1)) / list.reduce((sum, i) => sum + model.value[i]!, 0)));
|
|
111
|
+
const step = model.columns > 1 ? (width - nodeWidth) / (model.columns - 1) : 0;
|
|
112
|
+
const nodes = model.nodes.map(() => ({ x: 0, y0: 0, y1: 0 }));
|
|
113
|
+
for (const list of columns) {
|
|
114
|
+
const total = list.reduce((sum, i) => sum + model.value[i]! * ky, 0) + pad * Math.max(0, list.length - 1);
|
|
115
|
+
let y = top + (usable - total) / 2;
|
|
116
|
+
for (const i of list) {
|
|
117
|
+
const h = Math.max(1, model.value[i]! * ky);
|
|
118
|
+
nodes[i] = { x: model.depth[i]! * step, y0: y, y1: y + h };
|
|
119
|
+
y += h + pad;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Bands leave a node in the order of their targets and arrive in the order of their sources.
|
|
123
|
+
const out = new Map<number, number>();
|
|
124
|
+
const into = new Map<number, number>();
|
|
125
|
+
const centre = (i: number) => (nodes[i]!.y0 + nodes[i]!.y1) / 2;
|
|
126
|
+
const links = model.links.map(() => ({ sy: 0, ty: 0, width: 0 }));
|
|
127
|
+
const order = model.links.map((_, k) => k);
|
|
128
|
+
for (const k of [...order].sort((a, b) => centre(model.links[a]!.target) - centre(model.links[b]!.target))) {
|
|
129
|
+
const link = model.links[k]!;
|
|
130
|
+
const w = link.value * ky;
|
|
131
|
+
const at = out.get(link.source) ?? nodes[link.source]!.y0;
|
|
132
|
+
links[k]!.sy = at + w / 2;
|
|
133
|
+
links[k]!.width = w;
|
|
134
|
+
out.set(link.source, at + w);
|
|
135
|
+
}
|
|
136
|
+
for (const k of [...order].sort((a, b) => centre(model.links[a]!.source) - centre(model.links[b]!.source))) {
|
|
137
|
+
const link = model.links[k]!;
|
|
138
|
+
const at = into.get(link.target) ?? nodes[link.target]!.y0;
|
|
139
|
+
links[k]!.ty = at + links[k]!.width / 2;
|
|
140
|
+
into.set(link.target, at + links[k]!.width);
|
|
141
|
+
}
|
|
142
|
+
return { nodes, links };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
let shown: Layout | null = null;
|
|
146
|
+
let rects: SVGRectElement[] = [];
|
|
147
|
+
let bands: SVGPathElement[] = [];
|
|
148
|
+
let names: SVGTextElement[] = [];
|
|
149
|
+
let glide: gsap.core.Animation | null = null;
|
|
150
|
+
scope.add(() => glide?.kill());
|
|
151
|
+
const nodeWidth = () => Math.max(4, finite(options.nodeWidth) ? options.nodeWidth : 12);
|
|
152
|
+
|
|
153
|
+
const build = () => {
|
|
154
|
+
canvas.replaceChildren();
|
|
155
|
+
canvas.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
156
|
+
canvas.setAttribute('width', String(width));
|
|
157
|
+
canvas.setAttribute('height', String(height));
|
|
158
|
+
const defs = svg('defs');
|
|
159
|
+
const flows = svg('g', { 'data-mk-part': 'links' });
|
|
160
|
+
bands = model.links.map((link, k) => {
|
|
161
|
+
const gradient = svg('linearGradient', { id: `${uid}-${k}`, gradientUnits: 'userSpaceOnUse' });
|
|
162
|
+
const from = svg('stop', { offset: '0' });
|
|
163
|
+
from.style.stopColor = model.nodes[link.source]!.color;
|
|
164
|
+
const to = svg('stop', { offset: '1' });
|
|
165
|
+
to.style.stopColor = model.nodes[link.target]!.color;
|
|
166
|
+
gradient.append(from, to);
|
|
167
|
+
defs.append(gradient);
|
|
168
|
+
const band = svg('path', { 'data-mk-index': k });
|
|
169
|
+
band.style.fill = `url(#${uid}-${k})`;
|
|
170
|
+
flows.append(band);
|
|
171
|
+
return band;
|
|
172
|
+
});
|
|
173
|
+
const nodesLayer = svg('g', { 'data-mk-part': 'nodes' });
|
|
174
|
+
rects = model.nodes.map((node, i) => {
|
|
175
|
+
const rect = svg('rect', { width: nodeWidth(), rx: 3, 'data-mk-node': i });
|
|
176
|
+
rect.style.fill = node.color;
|
|
177
|
+
if (!model.used[i]) rect.style.display = 'none';
|
|
178
|
+
nodesLayer.append(rect);
|
|
179
|
+
return rect;
|
|
180
|
+
});
|
|
181
|
+
const labels = svg('g', { 'data-mk-part': 'labels' });
|
|
182
|
+
names = model.nodes.map((node, i) => {
|
|
183
|
+
const text = svg('text', { 'dominant-baseline': 'middle' });
|
|
184
|
+
text.textContent = node.label;
|
|
185
|
+
const amount = document.createElementNS(SVG_NS, 'tspan');
|
|
186
|
+
amount.setAttribute('data-mk-part', 'amount');
|
|
187
|
+
amount.textContent = ` ${format(model.value[i]!)}`;
|
|
188
|
+
text.append(amount);
|
|
189
|
+
if (!model.used[i]) text.style.display = 'none';
|
|
190
|
+
labels.append(text);
|
|
191
|
+
return text;
|
|
192
|
+
});
|
|
193
|
+
canvas.append(defs, flows, nodesLayer, labels);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const paint = (layout: Layout) => {
|
|
197
|
+
shown = layout;
|
|
198
|
+
const w = nodeWidth();
|
|
199
|
+
layout.nodes.forEach((node, i) => {
|
|
200
|
+
const rect = rects[i]!;
|
|
201
|
+
rect.setAttribute('x', String(round(node.x)));
|
|
202
|
+
rect.setAttribute('y', String(round(node.y0)));
|
|
203
|
+
rect.setAttribute('height', String(round(Math.max(1, node.y1 - node.y0))));
|
|
204
|
+
const last = model.depth[i] === model.columns - 1 && model.columns > 1;
|
|
205
|
+
const name = names[i]!;
|
|
206
|
+
const first = model.depth[i] === 0;
|
|
207
|
+
const x = round(last ? node.x - 8 : first ? node.x + w + 8 : node.x + w / 2);
|
|
208
|
+
const available = model.columns > 1 ? (width - w) / (model.columns - 1) - 20 : width - w - 16;
|
|
209
|
+
const characters = Math.max(3, Math.floor(available / 7));
|
|
210
|
+
const label = model.nodes[i]!.label;
|
|
211
|
+
if (name.firstChild) name.firstChild.textContent = label.length > characters ? `${label.slice(0, characters - 1)}…` : label;
|
|
212
|
+
name.setAttribute('x', String(x));
|
|
213
|
+
name.setAttribute('y', String(round((node.y0 + node.y1) / 2 - 7)));
|
|
214
|
+
name.setAttribute('text-anchor', last ? 'end' : first ? 'start' : 'middle');
|
|
215
|
+
const amount = name.querySelector('tspan');
|
|
216
|
+
amount?.setAttribute('x', String(x));
|
|
217
|
+
amount?.setAttribute('dy', '14');
|
|
218
|
+
});
|
|
219
|
+
layout.links.forEach((link, k) => {
|
|
220
|
+
const { source, target } = model.links[k]!;
|
|
221
|
+
const x0 = layout.nodes[source]!.x + w;
|
|
222
|
+
const x1 = layout.nodes[target]!.x;
|
|
223
|
+
const xm = (x0 + x1) / 2;
|
|
224
|
+
const h = link.width / 2;
|
|
225
|
+
const [s0, s1, t0, t1] = [link.sy - h, link.sy + h, link.ty - h, link.ty + h].map(round);
|
|
226
|
+
bands[k]!.setAttribute('d', `M${round(x0)},${s0}C${round(xm)},${s0} ${round(xm)},${t0} ${round(x1)},${t0}L${round(x1)},${t1}C${round(xm)},${t1} ${round(xm)},${s1} ${round(x0)},${s1}Z`);
|
|
227
|
+
const gradient = canvas.querySelector(`#${uid}-${k}`);
|
|
228
|
+
gradient?.setAttribute('x1', String(round(x0)));
|
|
229
|
+
gradient?.setAttribute('x2', String(round(x1)));
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const share = (part: number, whole: number) => new Intl.NumberFormat(undefined, { style: 'percent', maximumFractionDigits: 1 }).format(part / (whole || 1));
|
|
234
|
+
const paintSelection = (announce: boolean) => {
|
|
235
|
+
const lit = (k: number) => (selected !== null ? k === selected : hoverNode !== null ? model.links[k]!.source === hoverNode || model.links[k]!.target === hoverNode : false);
|
|
236
|
+
const any = selected !== null || hoverNode !== null;
|
|
237
|
+
bands.forEach((band, k) => {
|
|
238
|
+
band.toggleAttribute('data-mk-on', lit(k));
|
|
239
|
+
band.toggleAttribute('data-mk-dim', any && !lit(k));
|
|
240
|
+
});
|
|
241
|
+
rects.forEach((rect, i) => rect.toggleAttribute('data-mk-dim', any && !model.links.some((link, k) => lit(k) && (link.source === i || link.target === i))));
|
|
242
|
+
if (!shown || (selected === null && hoverNode === null)) {
|
|
243
|
+
frame.tip(null);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (selected !== null) {
|
|
247
|
+
const link = model.links[selected]!;
|
|
248
|
+
const geometry = shown.links[selected]!;
|
|
249
|
+
const x = (shown.nodes[link.source]!.x + shown.nodes[link.target]!.x + nodeWidth()) / 2;
|
|
250
|
+
const y = (geometry.sy + geometry.ty) / 2 - geometry.width / 2;
|
|
251
|
+
const from = model.nodes[link.source]!;
|
|
252
|
+
const to = model.nodes[link.target]!;
|
|
253
|
+
const rows = [{ name: `${share(link.value, model.value[link.source]!)} de ${from.label}`, color: from.color, value: format(link.value) }];
|
|
254
|
+
frame.tip({ x: round(x), y: round(y), title: `${from.label} → ${to.label}`, rows });
|
|
255
|
+
if (announce) frame.live.textContent = `${from.label} a ${to.label}: ${format(link.value)}`;
|
|
256
|
+
} else {
|
|
257
|
+
const i = hoverNode!;
|
|
258
|
+
const node = shown.nodes[i]!;
|
|
259
|
+
frame.tip({ x: round(node.x + nodeWidth() / 2), y: round(node.y0), title: model.nodes[i]!.label, rows: [{ name: 'Total', color: model.nodes[i]!.color, value: format(model.value[i]!) }] });
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const draw = (animate: boolean) => {
|
|
264
|
+
glide?.kill();
|
|
265
|
+
const name = options.label ?? 'Diagrama de flujo';
|
|
266
|
+
canvas.setAttribute('aria-label', hasData() ? `${name}. ${model.links.length} flujos entre ${model.used.filter(Boolean).length} etapas. Usa las flechas para recorrer los flujos.` : `${name}: ${options.emptyLabel ?? 'sin datos'}`);
|
|
267
|
+
frame.fillTable(name, ['Flujo', 'Valor'], model.links.map((link) => [`${model.nodes[link.source]!.label} → ${model.nodes[link.target]!.label}`, format(link.value)]));
|
|
268
|
+
frame.fillLegend([], false);
|
|
269
|
+
const ok = hasData();
|
|
270
|
+
frame.empty.hidden = ok;
|
|
271
|
+
frame.empty.textContent = options.emptyLabel ?? 'Sin datos para mostrar';
|
|
272
|
+
plot.hidden = !ok;
|
|
273
|
+
if (!ok) {
|
|
274
|
+
canvas.replaceChildren();
|
|
275
|
+
shown = null;
|
|
276
|
+
selected = null;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (selected !== null && selected >= model.links.length) selected = null;
|
|
280
|
+
const before = shown;
|
|
281
|
+
const target = layoutOf();
|
|
282
|
+
const comparable = before && before.nodes.length === target.nodes.length && before.links.length === target.links.length;
|
|
283
|
+
build();
|
|
284
|
+
const done = () => {
|
|
285
|
+
paint(target);
|
|
286
|
+
paintSelection(false);
|
|
287
|
+
};
|
|
288
|
+
if (!animate || reduced()) return done();
|
|
289
|
+
if (comparable) {
|
|
290
|
+
// Same nodes and flows: every node and band glides to its new place.
|
|
291
|
+
const state = { t: 0 };
|
|
292
|
+
const mix = (a: number, b: number) => a + (b - a) * state.t;
|
|
293
|
+
glide = gsap.to(state, {
|
|
294
|
+
t: 1,
|
|
295
|
+
duration: 0.6,
|
|
296
|
+
ease: EASE.swatch,
|
|
297
|
+
onUpdate: () =>
|
|
298
|
+
paint({
|
|
299
|
+
nodes: target.nodes.map((node, i) => ({ x: mix(before!.nodes[i]!.x, node.x), y0: mix(before!.nodes[i]!.y0, node.y0), y1: mix(before!.nodes[i]!.y1, node.y1) })),
|
|
300
|
+
links: target.links.map((link, k) => ({ sy: mix(before!.links[k]!.sy, link.sy), ty: mix(before!.links[k]!.ty, link.ty), width: mix(before!.links[k]!.width, link.width) })),
|
|
301
|
+
}),
|
|
302
|
+
onComplete: done,
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// First appearance: nodes grow from their middles column by column; the bands are wiped in from the left.
|
|
307
|
+
done();
|
|
308
|
+
const timeline = gsap.timeline();
|
|
309
|
+
rects.forEach((rect, i) => {
|
|
310
|
+
timeline.fromTo(rect, { scaleY: 0, transformOrigin: '50% 50%' }, { scaleY: 1, duration: 0.5, ease: EASE.swatch, clearProps: 'transform' }, (model.depth[i] ?? 0) * 0.12);
|
|
311
|
+
});
|
|
312
|
+
timeline.fromTo(names, { opacity: 0 }, { opacity: 1, duration: 0.4, stagger: 0.02, clearProps: 'opacity' }, 0.2);
|
|
313
|
+
const clip = svg('clipPath', { id: `${uid}-wipe` });
|
|
314
|
+
const rect = svg('rect', { x: 0, y: 0, width: 0, height });
|
|
315
|
+
clip.append(rect);
|
|
316
|
+
canvas.prepend(clip);
|
|
317
|
+
const flows = canvas.querySelector('[data-mk-part="links"]')!;
|
|
318
|
+
flows.setAttribute('clip-path', `url(#${uid}-wipe)`);
|
|
319
|
+
timeline.to(rect, { attr: { width }, duration: 0.9, ease: EASE.wipe, onComplete: () => {
|
|
320
|
+
flows.removeAttribute('clip-path');
|
|
321
|
+
clip.remove();
|
|
322
|
+
} }, 0.1);
|
|
323
|
+
glide = timeline;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const select = (index: number | null, announce = false) => {
|
|
327
|
+
if (scope.disposed) return;
|
|
328
|
+
const n = model.links.length;
|
|
329
|
+
const next = index === null || !n ? null : ((Math.round(index) % n) + n) % n;
|
|
330
|
+
if (next === selected) return;
|
|
331
|
+
selected = next;
|
|
332
|
+
paintSelection(announce);
|
|
333
|
+
options.onSelect?.(selected === null ? null : model.links[selected]!.given);
|
|
334
|
+
const link = selected === null ? null : model.links[selected]!;
|
|
335
|
+
root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, detail: { index: link?.given ?? null, source: link ? model.nodes[link.source]!.id : null, target: link ? model.nodes[link.target]!.id : null, value: link?.value ?? null } }));
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
scope.on(canvas, 'pointermove', (event) => {
|
|
339
|
+
const target = event.target as Element;
|
|
340
|
+
const band = target.closest('[data-mk-index]');
|
|
341
|
+
const node = target.closest('[data-mk-node]');
|
|
342
|
+
if (node) {
|
|
343
|
+
const i = Number(node.getAttribute('data-mk-node'));
|
|
344
|
+
if (selected !== null) select(null);
|
|
345
|
+
if (hoverNode !== i) {
|
|
346
|
+
hoverNode = i;
|
|
347
|
+
paintSelection(false);
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (hoverNode !== null) {
|
|
352
|
+
hoverNode = null;
|
|
353
|
+
paintSelection(false);
|
|
354
|
+
}
|
|
355
|
+
select(band ? Number(band.getAttribute('data-mk-index')) : null);
|
|
356
|
+
});
|
|
357
|
+
scope.on(canvas, 'pointerleave', (event) => {
|
|
358
|
+
if ((event as PointerEvent).pointerType !== 'mouse') return;
|
|
359
|
+
hoverNode = null;
|
|
360
|
+
select(null);
|
|
361
|
+
paintSelection(false);
|
|
362
|
+
});
|
|
363
|
+
scope.on(canvas, 'focus', () => {
|
|
364
|
+
if (selected === null && hasData()) select(0, true);
|
|
365
|
+
});
|
|
366
|
+
scope.on(canvas, 'blur', () => select(null));
|
|
367
|
+
scope.on(canvas, 'keydown', (event) => {
|
|
368
|
+
const key = (event as KeyboardEvent).key;
|
|
369
|
+
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.links.length - 1, Escape: null };
|
|
370
|
+
if (!(key in moves) || !hasData()) return;
|
|
371
|
+
event.preventDefault();
|
|
372
|
+
select(moves[key]!, true);
|
|
373
|
+
});
|
|
374
|
+
onWidth(root, scope, () => {
|
|
375
|
+
if (!hasData()) return;
|
|
376
|
+
const layout = layoutOf();
|
|
377
|
+
build();
|
|
378
|
+
paint(layout);
|
|
379
|
+
paintSelection(false);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
draw(true);
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
update(next) {
|
|
386
|
+
if (scope.disposed) return;
|
|
387
|
+
options = { ...options, ...next };
|
|
388
|
+
model = modelOf();
|
|
389
|
+
draw(true);
|
|
390
|
+
},
|
|
391
|
+
select: (index) => {
|
|
392
|
+
// `index` is the flow's place in `links`.
|
|
393
|
+
const at = index === null ? null : model.links.findIndex((link) => link.given === index);
|
|
394
|
+
select(at === null || at < 0 ? null : at);
|
|
395
|
+
},
|
|
396
|
+
get selected() {
|
|
397
|
+
return selected === null ? null : model.links[selected]!.given;
|
|
398
|
+
},
|
|
399
|
+
destroy: () => scope.dispose(),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import type { HTMLAttributes } from 'react';
|
|
3
|
+
import { useChart } from '../chart/use-chart';
|
|
4
|
+
import { createSankeyChart, type SankeyChartInstance, type SankeyChartOptions } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface SankeyChartProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect' | 'children'>, SankeyChartOptions {
|
|
7
|
+
instanceRef?: { current: SankeyChartInstance | null };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** <SankeyChart label="Embudo" nodes={[{ id: 'visitas' }, …]} links={[{ source: 'visitas', target: 'carrito', value: 420 }, …]} /> */
|
|
11
|
+
export function SankeyChart({ nodes, links, height, nodeWidth, nodePadding, label, emptyLabel, formatValue, onSelect, instanceRef, className, ...props }: SankeyChartProps) {
|
|
12
|
+
const ref = useChart(createSankeyChart, { nodes, links, height, nodeWidth, nodePadding, label, emptyLabel } as SankeyChartOptions, { formatValue, onSelect }, instanceRef);
|
|
13
|
+
return <div {...props} ref={ref} className={['mk-chart mk-sankey-chart', className].filter(Boolean).join(' ')} />;
|
|
14
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/* sankey-chart — bands that fade from their source's colour to their target's (35 %), nodes with 3 px corners;
|
|
2
|
+
needs chart.css for the family. The flow under the pointer lights up, the rest step back. */
|
|
3
|
+
.mk-sankey-chart,
|
|
4
|
+
mk-sankey-chart {
|
|
5
|
+
display: grid;
|
|
6
|
+
gap: 1rem;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
[data-mk-chart-type='sankey'] [data-mk-chart-svg] {
|
|
10
|
+
cursor: pointer;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
[data-mk-chart-type='sankey'] [data-mk-part='links'] path {
|
|
14
|
+
opacity: 0.35;
|
|
15
|
+
transition: opacity 0.2s ease;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
[data-mk-chart-type='sankey'] [data-mk-part='links'] path[data-mk-on] {
|
|
19
|
+
opacity: 0.7;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
[data-mk-chart-type='sankey'] [data-mk-part='links'] path[data-mk-dim] {
|
|
23
|
+
opacity: 0.1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
[data-mk-chart-type='sankey'] [data-mk-part='nodes'] rect {
|
|
27
|
+
transition: opacity 0.2s ease;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
[data-mk-chart-type='sankey'] [data-mk-part='nodes'] rect[data-mk-dim] {
|
|
31
|
+
opacity: 0.45;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
[data-mk-chart-type='sankey'] [data-mk-part='labels'] text {
|
|
35
|
+
fill: var(--mk-text);
|
|
36
|
+
font-size: 12px;
|
|
37
|
+
font-weight: 500;
|
|
38
|
+
paint-order: stroke;
|
|
39
|
+
stroke: var(--mk-chart-surface);
|
|
40
|
+
stroke-width: 3px;
|
|
41
|
+
stroke-linejoin: round;
|
|
42
|
+
pointer-events: none;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
[data-mk-chart-type='sankey'] [data-mk-part='amount'] {
|
|
46
|
+
fill: var(--mk-muted);
|
|
47
|
+
font-weight: 400;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@media (prefers-reduced-motion: reduce) {
|
|
51
|
+
[data-mk-chart-type='sankey'] :is([data-mk-part='links'] path, [data-mk-part='nodes'] rect) {
|
|
52
|
+
transition: none;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@media (forced-colors: active) {
|
|
57
|
+
[data-mk-chart-type='sankey'] [data-mk-part='nodes'] rect {
|
|
58
|
+
fill: CanvasText !important;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# scroll-island
|
|
2
|
+
|
|
3
|
+
Una barra fina que se transforma en una píldora con el porcentaje y la sección actual al acercar el puntero al borde derecho. Arrastra la píldora o pulsa su carril para desplazarte.
|
|
4
|
+
|
|
5
|
+
## React
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import '@yewdiel21/motion-kit/css';
|
|
9
|
+
import { ScrollIsland } from '@yewdiel21/motion-kit/react';
|
|
10
|
+
|
|
11
|
+
<ScrollIsland persistent style={{ height: 360, overflow: 'auto' }}
|
|
12
|
+
tabIndex={0} role="region" aria-label="Guía de diseño">
|
|
13
|
+
<section style={{ minHeight: 400 }}><h3>Introducción</h3>Contenido…</section>
|
|
14
|
+
<section style={{ minHeight: 400 }}><h3>Detalles</h3>Contenido…</section>
|
|
15
|
+
</ScrollIsland>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**El contenedor debe tener altura limitada, overflow y contenido suficiente para desplazarse.** Si todo cabe, la barra se oculta. `persistent` deja visible la barra fina mientras está en reposo; por defecto aparece al desplazarse y desaparece después.
|
|
19
|
+
|
|
20
|
+
## HTML / Astro
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<mk-scroll-island persistent style="display:block;height:360px;overflow:auto"
|
|
24
|
+
tabindex="0" role="region" aria-label="Guía">
|
|
25
|
+
<section style="min-height:400px"><h3>Introducción</h3>Contenido…</section>
|
|
26
|
+
<section style="min-height:400px"><h3>Detalles</h3>Contenido…</section>
|
|
27
|
+
</mk-scroll-island>
|
|
28
|
+
<script>
|
|
29
|
+
import { defineElements } from '@yewdiel21/motion-kit/elements';
|
|
30
|
+
defineElements(['mk-scroll-island']);
|
|
31
|
+
</script>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Opciones
|
|
35
|
+
|
|
36
|
+
- `page`: sigue el scroll de la página en vez del contenedor. En React `<ScrollIsland page />` no dibuja contenedor propio.
|
|
37
|
+
- `sections`: selector de encabezados, `h2, h3` por defecto; `false` omite el nombre de sección en la factory/React.
|
|
38
|
+
- `reach`: distancia del borde que activa la píldora, 56 px.
|
|
39
|
+
- `idle`: demora para ocultarse, 900 ms.
|
|
40
|
+
- `persistent`: mantiene la barra fina visible, false por defecto.
|
|
41
|
+
- `onSection(section)`: cambio de sección, también `mk:section` con `detail.section`.
|
|
42
|
+
- Factory `createScrollIsland(root, options)`; instancia `refresh()` y `destroy()`.
|
|
43
|
+
|
|
44
|
+
El overlay decorativo usa `aria-hidden`; el contenido mantiene su desplazamiento nativo por teclado y tacto. Dale `tabindex="0"` y un nombre al panel. Respeta movimiento reducido, se adapta al tamaño del contenedor y elimina el overlay al desmontarse.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createScrollIsland, type ScrollIslandInstance } from './engine';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* <mk-scroll-island class="mk-scroll-island" style="height: 24rem; overflow: auto">…</mk-scroll-island>
|
|
6
|
+
* or <mk-scroll-island page></mk-scroll-island> for the page. Emits mk:section ({section}).
|
|
7
|
+
*/
|
|
8
|
+
export class MkScrollIslandElement extends MkElement<ScrollIslandInstance> {
|
|
9
|
+
static options: Record<string, OptionType> = { sections: 'string', reach: 'number', idle: 'number', persistent: 'boolean', page: 'boolean' };
|
|
10
|
+
|
|
11
|
+
protected create(options: Record<string, unknown>) {
|
|
12
|
+
return createScrollIsland(options.page ? document.documentElement : this, options);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const defineScrollIsland = () => defineElement('mk-scroll-island', MkScrollIslandElement);
|