@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,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,34 @@
|
|
|
1
|
+
# scroll-island
|
|
2
|
+
|
|
3
|
+
Qué hace, en una frase.
|
|
4
|
+
|
|
5
|
+
Origen: archivo del kit y parámetros (tiempos, curvas).
|
|
6
|
+
|
|
7
|
+
## HTML / Astro
|
|
8
|
+
|
|
9
|
+
```html
|
|
10
|
+
<mk-scroll-island></mk-scroll-island>
|
|
11
|
+
<script>
|
|
12
|
+
import { defineElements } from '@yewdiel21/motion-kit/elements';
|
|
13
|
+
defineElements(['mk-scroll-island']);
|
|
14
|
+
</script>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## React
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
<ScrollIsland />
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Opciones
|
|
24
|
+
|
|
25
|
+
| Opción | Atributo | Por defecto | Qué hace |
|
|
26
|
+
|---|---|---|---|
|
|
27
|
+
|
|
28
|
+
## Variables CSS
|
|
29
|
+
|
|
30
|
+
`--mk-scroll-island-radius`
|
|
31
|
+
|
|
32
|
+
## Accesibilidad
|
|
33
|
+
|
|
34
|
+
Roles, foco y teclado. Con movimiento reducido…
|
|
@@ -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', 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);
|