@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
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type FilterField, type FilterGroup, type FilterNode, type FilterRule,
|
|
8
8
|
} from '../../core/filter';
|
|
9
9
|
import { blurIn, collapse, press, rollDigits, shake } from '../../motion/micro';
|
|
10
|
+
import { createSelect, type SelectInstance } from '../select/engine';
|
|
10
11
|
import { slideIndicator } from '../../motion/indicator';
|
|
11
12
|
|
|
12
13
|
/*
|
|
@@ -102,6 +103,28 @@ export function createFilterBuilder(root: HTMLElement | null, options: FilterBui
|
|
|
102
103
|
root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { value: cloneFilter(model), summary: describeFilter(model, fields) } }));
|
|
103
104
|
};
|
|
104
105
|
|
|
106
|
+
// ── Choices: every <select> is the kit's select (it opens the kit's menu), kept in step with the select itself, so
|
|
107
|
+
// the change handlers below read it as always. Each one lives as long as its row.
|
|
108
|
+
const selects = new Map<HTMLElement, SelectInstance>();
|
|
109
|
+
const choice = (select: HTMLSelectElement, label: string) => {
|
|
110
|
+
const box = el('span', { class: 'mk-select', 'data-mk-fb-select': '' });
|
|
111
|
+
box.append(select);
|
|
112
|
+
selects.set(box, createSelect(box, { label }));
|
|
113
|
+
return box;
|
|
114
|
+
};
|
|
115
|
+
/** The selects inside `container` go (before it is emptied or removed). */
|
|
116
|
+
const dropChoices = (container: Element) => {
|
|
117
|
+
for (const [box, select] of selects) {
|
|
118
|
+
if (!container.contains(box)) continue;
|
|
119
|
+
select.destroy();
|
|
120
|
+
selects.delete(box);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
scope.add(() => {
|
|
124
|
+
for (const select of selects.values()) select.destroy();
|
|
125
|
+
selects.clear();
|
|
126
|
+
});
|
|
127
|
+
|
|
105
128
|
// ── Value controls ───────────────────────────────────────────────────────
|
|
106
129
|
const valueControl = (rule: FilterRule, index: 0 | 1) => {
|
|
107
130
|
const field = fieldOf(rule.field);
|
|
@@ -111,6 +134,9 @@ export function createFilterBuilder(root: HTMLElement | null, options: FilterBui
|
|
|
111
134
|
if (type === 'select') {
|
|
112
135
|
control = el('select');
|
|
113
136
|
control.append(el('option', { value: '' }, 'Elige…'), ...(field?.options ?? []).map((option) => el('option', { value: option.value }, option.label)));
|
|
137
|
+
control.dataset.mkFbValue = String(index);
|
|
138
|
+
control.value = current === null || current === undefined ? '' : String(current);
|
|
139
|
+
return choice(control, index === 1 ? 'Hasta' : operatorOf(field, rule.operator).arity === 2 ? 'Desde' : 'Valor');
|
|
114
140
|
} else {
|
|
115
141
|
control = el('input');
|
|
116
142
|
control.type = type === 'number' ? 'text' : type === 'date' ? 'date' : 'text';
|
|
@@ -126,6 +152,7 @@ export function createFilterBuilder(root: HTMLElement | null, options: FilterBui
|
|
|
126
152
|
const fillValues = (row: HTMLElement, rule: FilterRule) => {
|
|
127
153
|
const host = row.querySelector<HTMLElement>('[data-mk-fb-values]')!;
|
|
128
154
|
const { arity } = operatorOf(fieldOf(rule.field), rule.operator);
|
|
155
|
+
dropChoices(host);
|
|
129
156
|
host.replaceChildren();
|
|
130
157
|
if (arity === 0) return;
|
|
131
158
|
host.append(valueControl(rule, 0));
|
|
@@ -147,7 +174,7 @@ export function createFilterBuilder(root: HTMLElement | null, options: FilterBui
|
|
|
147
174
|
const values = el('span', { 'data-mk-fb-values': '' });
|
|
148
175
|
const remove = el('button', { type: 'button', 'data-mk-fb-remove': '', 'aria-label': 'Quitar condición' });
|
|
149
176
|
remove.innerHTML = CLOSE;
|
|
150
|
-
row.append(field, operator, values, remove);
|
|
177
|
+
row.append(choice(field, 'Campo'), choice(operator, 'Operador'), values, remove);
|
|
151
178
|
fillOperators(row, rule);
|
|
152
179
|
fillValues(row, rule);
|
|
153
180
|
return row;
|
|
@@ -195,6 +222,7 @@ export function createFilterBuilder(root: HTMLElement | null, options: FilterBui
|
|
|
195
222
|
};
|
|
196
223
|
|
|
197
224
|
const render = () => {
|
|
225
|
+
dropChoices(tree);
|
|
198
226
|
tree.replaceChildren(groupBox(model, 0));
|
|
199
227
|
placeIndicators(true);
|
|
200
228
|
describe();
|
|
@@ -220,7 +248,7 @@ export function createFilterBuilder(root: HTMLElement | null, options: FilterBui
|
|
|
220
248
|
parts.push(element);
|
|
221
249
|
if (isGroup(node)) placeIndicators(true);
|
|
222
250
|
keep(blurIn(parts, { from: 'rise', stagger: 0.04 }));
|
|
223
|
-
element.querySelector<HTMLElement>('select, input')?.focus();
|
|
251
|
+
element.querySelector<HTMLElement>('[data-mk-select-trigger], input')?.focus();
|
|
224
252
|
emit();
|
|
225
253
|
};
|
|
226
254
|
|
|
@@ -236,6 +264,7 @@ export function createFilterBuilder(root: HTMLElement | null, options: FilterBui
|
|
|
236
264
|
const box = boxOf(parent.id)!;
|
|
237
265
|
const focusAfter = box.querySelector<HTMLElement>(':scope > [data-mk-fb-actions] button');
|
|
238
266
|
const gone = () => {
|
|
267
|
+
dropChoices(element);
|
|
239
268
|
element.remove();
|
|
240
269
|
link?.remove();
|
|
241
270
|
};
|
|
@@ -292,6 +321,8 @@ export function createFilterBuilder(root: HTMLElement | null, options: FilterBui
|
|
|
292
321
|
setCombinator(group, next);
|
|
293
322
|
option.parentElement!.querySelector<HTMLElement>(`[data-combinator="${next}"]`)!.focus();
|
|
294
323
|
});
|
|
324
|
+
// The selects' own events stay inside: listeners on the builder hear the builder's mk:change.
|
|
325
|
+
scope.on(tree, 'mk:change', (event) => event.stopPropagation());
|
|
295
326
|
scope.on(tree, 'change', (event) => {
|
|
296
327
|
const target = event.target as HTMLElement;
|
|
297
328
|
const row = target.closest<HTMLElement>('[data-mk-fb-rule]');
|
|
@@ -88,7 +88,13 @@ mk-filter-builder {
|
|
|
88
88
|
min-width: 0;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
/* Choices are the kit's select (select.css draws the button); inputs share its look. */
|
|
92
|
+
[data-mk-fb-rule] .mk-select {
|
|
93
|
+
min-width: 8rem;
|
|
94
|
+
max-width: 100%;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
[data-mk-fb-rule] input {
|
|
92
98
|
box-sizing: border-box;
|
|
93
99
|
min-width: 0;
|
|
94
100
|
height: 2.5rem;
|
|
@@ -103,22 +109,11 @@ mk-filter-builder {
|
|
|
103
109
|
transition: box-shadow 0.2s ease;
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
[data-mk-fb-rule] select {
|
|
107
|
-
max-width: 100%;
|
|
108
|
-
padding-right: 1.9rem;
|
|
109
|
-
appearance: none;
|
|
110
|
-
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 9l6 6 6-6' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
111
|
-
background-position: right 0.55rem center;
|
|
112
|
-
background-repeat: no-repeat;
|
|
113
|
-
background-size: 1rem;
|
|
114
|
-
cursor: pointer;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
112
|
[data-mk-fb-rule] input {
|
|
118
113
|
width: 9rem;
|
|
119
114
|
}
|
|
120
115
|
|
|
121
|
-
[data-mk-fb-rule]
|
|
116
|
+
[data-mk-fb-rule] input:focus-visible {
|
|
122
117
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--mk-accent) 35%, transparent);
|
|
123
118
|
}
|
|
124
119
|
|
|
@@ -217,7 +212,7 @@ mk-filter-builder {
|
|
|
217
212
|
}
|
|
218
213
|
|
|
219
214
|
@media (max-width: 480px) {
|
|
220
|
-
[data-mk-fb-rule] :is(select, input) {
|
|
215
|
+
[data-mk-fb-rule] :is(.mk-select, input) {
|
|
221
216
|
flex: 1 1 8rem;
|
|
222
217
|
}
|
|
223
218
|
}
|
|
@@ -225,7 +220,7 @@ mk-filter-builder {
|
|
|
225
220
|
@media (prefers-reduced-motion: reduce) {
|
|
226
221
|
[data-mk-fb-combinator] [role='radio'],
|
|
227
222
|
[data-mk-fb-actions] button,
|
|
228
|
-
[data-mk-fb-rule]
|
|
223
|
+
[data-mk-fb-rule] input {
|
|
229
224
|
transition: none;
|
|
230
225
|
}
|
|
231
226
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# heatmap-chart
|
|
2
|
+
|
|
3
|
+
Calendario de actividad tipo GitHub con intensidad por día, tooltips y desplazamiento horizontal.
|
|
4
|
+
|
|
5
|
+
## Instalación
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import '@yewdiel21/motion-kit/css';
|
|
9
|
+
import { HeatmapChart } from '@yewdiel21/motion-kit/react';
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npx @yewdiel21/motion-kit add heatmap-chart --react --elements
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## React
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
<HeatmapChart end="2026-10-31" weeks={26} data={{ "2026-10-30": 4, "2026-10-31": 8 }} unit="sesiones" locale="es-MX" />
|
|
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-heatmap-chart end="2026-10-31" weeks="26" data='{"2026-10-30":4,"2026-10-31":8}'></mk-heatmap-chart>
|
|
28
|
+
<script type="module">
|
|
29
|
+
import { defineElements } from '@yewdiel21/motion-kit/elements';
|
|
30
|
+
defineElements(['mk-heatmap-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 { createHeatmapChart } 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: {date, value}[] o mapa YYYY-MM-DD a valor. end: hoy. weeks: 53. firstDay: 1 (lunes). levels: 4. color: acento. locale, unit, legend. onSelect(date, value). Instancia: select(date | null), update(options), selected.
|
|
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-heatmap-color, --mk-heatmap-empty; --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,21 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createHeatmapChart, type HeatmapChartInstance, type HeatmapChartOptions } from './engine';
|
|
3
|
+
|
|
4
|
+
/** <mk-heatmap-chart></mk-heatmap-chart> */
|
|
5
|
+
export class MkHeatmapChartElement extends MkElement<HeatmapChartInstance> {
|
|
6
|
+
static options: Record<string, OptionType> = { data: 'json', end: 'string', weeks: 'number', firstDay: 'number', locale: 'string', levels: 'number', color: 'string', unit: 'string', label: 'string', legend: 'boolean', emptyLabel: 'string' };
|
|
7
|
+
|
|
8
|
+
protected create(options: Record<string, unknown>) {
|
|
9
|
+
return createHeatmapChart(this, options);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
attributeChangedCallback(_name: string, previous: string | null, next: string | null) {
|
|
13
|
+
if (previous === next || !this.instance) return;
|
|
14
|
+
this.instance.update({ legend: undefined, color: undefined, ...this.options });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
update(options: HeatmapChartOptions) { this.instance?.update(options); }
|
|
18
|
+
select(date: string | null) { this.instance?.select(date); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const defineHeatmapChart = () => defineElement('mk-heatmap-chart', MkHeatmapChartElement);
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { gsap, EASE, registerMotion } from '../../core/gsap';
|
|
2
|
+
import { addDays, capitalize, dayKey, safeFormat, startOfWeek, toDate, weekdayNames, type DateInput } from '../../core/dates';
|
|
3
|
+
import { reduced } from '../../core/reduced';
|
|
4
|
+
import { createScope } from '../../core/scope';
|
|
5
|
+
import { inert, type Instance } from '../../core/types';
|
|
6
|
+
import { chartFrame, finite, formatNumber, onWidth, round, svg } from '../chart/parts';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Heatmap chart: daily activity on a calendar, a column per week like
|
|
10
|
+
* GitHub's, in chart's family. Each day is a 3 px rounded cell whose colour
|
|
11
|
+
* deepens in steps (four by default) from the track to the accent. The cells
|
|
12
|
+
* pop in along the diagonal, oldest first (.35 s, 8 ms apart); new data
|
|
13
|
+
* crossfades their colours. Pointing at a day (or the arrows: up and down a
|
|
14
|
+
* day, left and right a week, PageUp/PageDown four) lifts it with a ring and
|
|
15
|
+
* the tooltip says its value and date; the "less … more" scale lights the
|
|
16
|
+
* days of a step. Narrow screens scroll the weeks, ending on the latest.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface HeatmapDatum {
|
|
20
|
+
date: DateInput;
|
|
21
|
+
value: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface HeatmapChartOptions {
|
|
25
|
+
/** Values by day: a list or a { 'YYYY-MM-DD': value } map. */
|
|
26
|
+
data?: HeatmapDatum[] | Record<string, number>;
|
|
27
|
+
/** The last day shown (default: today). */
|
|
28
|
+
end?: DateInput;
|
|
29
|
+
/** Weeks shown (default 53). */
|
|
30
|
+
weeks?: number;
|
|
31
|
+
/** First day of the week, 0 Sunday … 6 Saturday (default 1, Monday). */
|
|
32
|
+
firstDay?: number;
|
|
33
|
+
locale?: string;
|
|
34
|
+
/** Coloured steps above zero (default 4). */
|
|
35
|
+
levels?: number;
|
|
36
|
+
/** The deepest step's colour (default the accent). */
|
|
37
|
+
color?: string;
|
|
38
|
+
/** What a value counts, for the tooltip (default 'actividades'). */
|
|
39
|
+
unit?: string;
|
|
40
|
+
label?: string;
|
|
41
|
+
/** The "less … more" scale (default true). */
|
|
42
|
+
legend?: boolean;
|
|
43
|
+
formatValue?: (value: number) => string;
|
|
44
|
+
emptyLabel?: string;
|
|
45
|
+
onSelect?: (date: string | null, value: number | null) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface HeatmapChartInstance extends Instance {
|
|
49
|
+
update(options: HeatmapChartOptions): void;
|
|
50
|
+
/** Choose a day ('YYYY-MM-DD') or none. */
|
|
51
|
+
select(date: string | null): void;
|
|
52
|
+
readonly selected: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const LEFT = 28;
|
|
56
|
+
const TOP = 18;
|
|
57
|
+
|
|
58
|
+
export function createHeatmapChart(root: HTMLElement | null, initial: HeatmapChartOptions = {}): HeatmapChartInstance {
|
|
59
|
+
if (!root) return inert({ update() {}, select() {}, selected: null });
|
|
60
|
+
registerMotion();
|
|
61
|
+
const scope = createScope();
|
|
62
|
+
const frame = chartFrame(root, scope, 'heatmap');
|
|
63
|
+
const { plot, canvas, keep } = frame;
|
|
64
|
+
let options: HeatmapChartOptions = { ...initial };
|
|
65
|
+
let selected: number | null = null;
|
|
66
|
+
|
|
67
|
+
// The weeks scroll inside the plot; the tooltip stays over it, unclipped.
|
|
68
|
+
const scroller = document.createElement('div');
|
|
69
|
+
scroller.dataset.mkHeatmapScroll = '';
|
|
70
|
+
plot.prepend(scroller);
|
|
71
|
+
scroller.append(canvas);
|
|
72
|
+
scope.add(() => scroller.remove());
|
|
73
|
+
|
|
74
|
+
const scale = document.createElement('div');
|
|
75
|
+
scale.dataset.mkHeatmapScale = '';
|
|
76
|
+
scale.setAttribute('aria-hidden', 'true');
|
|
77
|
+
root.insertBefore(scale, frame.legend);
|
|
78
|
+
scope.add(() => scale.remove());
|
|
79
|
+
|
|
80
|
+
const locale = () => options.locale ?? (document.documentElement.lang || 'es');
|
|
81
|
+
const format = (value: number) => (options.formatValue ? options.formatValue(value) : formatNumber(value));
|
|
82
|
+
const levels = () => Math.max(1, Math.min(8, Math.round(options.levels ?? 4)));
|
|
83
|
+
|
|
84
|
+
const modelOf = () => {
|
|
85
|
+
const values = new Map<string, number>();
|
|
86
|
+
const add = (key: string, value: number) => finite(value) && values.set(key, (values.get(key) ?? 0) + value);
|
|
87
|
+
if (Array.isArray(options.data)) for (const datum of options.data) add(dayKey(toDate(datum.date)), datum.value);
|
|
88
|
+
else if (options.data) for (const [key, value] of Object.entries(options.data)) add(dayKey(toDate(key)), value);
|
|
89
|
+
const weeks = Math.max(1, Math.min(106, Math.round(options.weeks ?? 53)));
|
|
90
|
+
const firstDay = Number.isInteger(options.firstDay) && options.firstDay! >= 0 && options.firstDay! <= 6 ? options.firstDay! : 1;
|
|
91
|
+
const end = toDate(options.end ?? new Date());
|
|
92
|
+
const start = startOfWeek(addDays(end, -(weeks - 1) * 7), firstDay);
|
|
93
|
+
const days: Array<{ key: string; date: Date; value: number; week: number; day: number }> = [];
|
|
94
|
+
for (let date = start, i = 0; date <= end; date = addDays(date, 1), i++) {
|
|
95
|
+
const key = dayKey(date);
|
|
96
|
+
days.push({ key, date, value: values.get(key) ?? 0, week: Math.floor(i / 7), day: i % 7 });
|
|
97
|
+
}
|
|
98
|
+
const max = Math.max(0, ...days.map((each) => each.value));
|
|
99
|
+
return { days, weeks: (days[days.length - 1]?.week ?? 0) + 1, firstDay, max, total: days.reduce((sum, each) => sum + each.value, 0) };
|
|
100
|
+
};
|
|
101
|
+
let model = modelOf();
|
|
102
|
+
const levelOf = (value: number) => (value <= 0 || !model.max ? 0 : Math.max(1, Math.ceil((value / model.max) * levels())));
|
|
103
|
+
const fillOf = (level: number) => (level === 0 ? 'var(--mk-heatmap-empty)' : `color-mix(in srgb, ${options.color ?? 'var(--mk-heatmap-color)'} ${Math.round(22 + (78 * level) / levels())}%, var(--mk-heatmap-empty))`);
|
|
104
|
+
|
|
105
|
+
let cells: SVGRectElement[] = [];
|
|
106
|
+
/** The first day drawn: the same days keep their cells. */
|
|
107
|
+
let builtFrom = '';
|
|
108
|
+
let geometry = { cell: 12, gap: 3, width: 0, height: 0 };
|
|
109
|
+
const longDate = () => safeFormat(locale(), { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
|
110
|
+
|
|
111
|
+
const build = (animate: boolean) => {
|
|
112
|
+
canvas.replaceChildren();
|
|
113
|
+
const available = Math.max(0, (plot.clientWidth || root.clientWidth || 0) - LEFT);
|
|
114
|
+
const gap = 3;
|
|
115
|
+
// Cells fill the width between 10 and 14 px; past that the weeks scroll.
|
|
116
|
+
const fit = available ? Math.floor(available / model.weeks - gap) : 12;
|
|
117
|
+
const cell = Math.max(10, Math.min(14, fit));
|
|
118
|
+
const width = LEFT + model.weeks * (cell + gap);
|
|
119
|
+
const height = TOP + 7 * (cell + gap);
|
|
120
|
+
geometry = { cell, gap, width, height };
|
|
121
|
+
canvas.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
122
|
+
canvas.setAttribute('width', String(width));
|
|
123
|
+
canvas.setAttribute('height', String(height));
|
|
124
|
+
plot.style.setProperty('--mk-heatmap-width', `${width}px`);
|
|
125
|
+
|
|
126
|
+
// Month names over the week where each month starts; weekday names on every other row.
|
|
127
|
+
const months = svg('g', { 'data-mk-part': 'months' });
|
|
128
|
+
const monthName = safeFormat(locale(), { month: 'short' });
|
|
129
|
+
let last = -1;
|
|
130
|
+
for (const each of model.days) {
|
|
131
|
+
if (each.day !== 0) continue;
|
|
132
|
+
const month = addDays(each.date, 6).getMonth();
|
|
133
|
+
if (month === last) continue;
|
|
134
|
+
last = month;
|
|
135
|
+
if (each.week > model.weeks - 2 && model.weeks > 4) continue;
|
|
136
|
+
const text = svg('text', { x: LEFT + each.week * (cell + gap), y: 10 });
|
|
137
|
+
text.textContent = capitalize(monthName.format(addDays(each.date, 6)).replace('.', ''));
|
|
138
|
+
months.append(text);
|
|
139
|
+
}
|
|
140
|
+
const names = weekdayNames(locale(), model.firstDay, 'short');
|
|
141
|
+
const weekdays = svg('g', { 'data-mk-part': 'weekdays' });
|
|
142
|
+
[1, 3, 5].forEach((row) => {
|
|
143
|
+
const text = svg('text', { x: 0, y: round(TOP + row * (cell + gap) + cell / 2), 'dominant-baseline': 'middle' });
|
|
144
|
+
text.textContent = capitalize(names[row]!.replace('.', '')).slice(0, 3);
|
|
145
|
+
weekdays.append(text);
|
|
146
|
+
});
|
|
147
|
+
const grid = svg('g', { 'data-mk-part': 'cells' });
|
|
148
|
+
cells = model.days.map((each, i) => {
|
|
149
|
+
const rect = svg('rect', { x: LEFT + each.week * (cell + gap), y: TOP + each.day * (cell + gap), width: cell, height: cell, rx: 3, 'data-mk-index': i, 'data-mk-level': levelOf(each.value) });
|
|
150
|
+
rect.style.fill = fillOf(levelOf(each.value));
|
|
151
|
+
grid.append(rect);
|
|
152
|
+
return rect;
|
|
153
|
+
});
|
|
154
|
+
canvas.append(months, weekdays, grid);
|
|
155
|
+
builtFrom = model.days[0]?.key ?? '';
|
|
156
|
+
// The latest weeks in view when they scroll.
|
|
157
|
+
scroller.scrollLeft = scroller.scrollWidth;
|
|
158
|
+
if (animate && !reduced()) keep(gsap.fromTo(cells, { opacity: 0, scale: 0.3 }, { opacity: 1, scale: 1, duration: 0.35, ease: EASE.pop, clearProps: 'opacity,transform', stagger: (i: number) => Math.min(0.7, (model.days[i]!.week + model.days[i]!.day) * 0.008) }));
|
|
159
|
+
};
|
|
160
|
+
scope.add(() => plot.style.removeProperty('--mk-heatmap-width'));
|
|
161
|
+
|
|
162
|
+
const paintScale = () => {
|
|
163
|
+
scale.replaceChildren();
|
|
164
|
+
scale.hidden = options.legend === false || !model.days.length;
|
|
165
|
+
if (scale.hidden) return;
|
|
166
|
+
const less = document.createElement('span');
|
|
167
|
+
less.textContent = 'Menos';
|
|
168
|
+
scale.append(less);
|
|
169
|
+
for (let level = 0; level <= levels(); level++) {
|
|
170
|
+
const swatch = document.createElement('i');
|
|
171
|
+
swatch.style.background = fillOf(level);
|
|
172
|
+
swatch.addEventListener('pointerenter', () => {
|
|
173
|
+
for (const cell of cells) cell.toggleAttribute('data-mk-dim', cell.getAttribute('data-mk-level') !== String(level));
|
|
174
|
+
});
|
|
175
|
+
swatch.addEventListener('pointerleave', () => {
|
|
176
|
+
for (const cell of cells) cell.removeAttribute('data-mk-dim');
|
|
177
|
+
});
|
|
178
|
+
scale.append(swatch);
|
|
179
|
+
}
|
|
180
|
+
const more = document.createElement('span');
|
|
181
|
+
more.textContent = 'Más';
|
|
182
|
+
scale.append(more);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const unit = () => options.unit ?? 'actividades';
|
|
186
|
+
const paintSelection = (announce: boolean) => {
|
|
187
|
+
cells.forEach((cell, i) => cell.toggleAttribute('data-mk-on', i === selected));
|
|
188
|
+
const each = selected === null ? null : model.days[selected];
|
|
189
|
+
if (!each) {
|
|
190
|
+
frame.tip(null);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const { cell, gap } = geometry;
|
|
194
|
+
const x = LEFT + each.week * (cell + gap) + cell / 2 - scroller.scrollLeft;
|
|
195
|
+
const y = TOP + each.day * (cell + gap);
|
|
196
|
+
const title = capitalize(longDate().format(each.date));
|
|
197
|
+
frame.tip({ x, y, title, rows: [{ name: unit(), color: fillOf(Math.max(1, levelOf(each.value))), value: format(each.value) }] });
|
|
198
|
+
if (announce) frame.live.textContent = `${title}: ${format(each.value)} ${unit()}`;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const draw = (animate: boolean) => {
|
|
202
|
+
const name = options.label ?? 'Actividad diaria';
|
|
203
|
+
canvas.setAttribute('aria-label', model.days.length ? `${name}. ${model.weeks} semanas, ${format(model.total)} ${unit()} en total. Usa las flechas para recorrer los días.` : `${name}: ${options.emptyLabel ?? 'sin datos'}`);
|
|
204
|
+
// The days with something, for screen readers (a year of empty rows says nothing).
|
|
205
|
+
const active = model.days.filter((each) => each.value > 0);
|
|
206
|
+
frame.fillTable(`${name}: ${format(model.total)} ${unit()}`, ['Día', capitalize(unit())], active.map((each) => [capitalize(longDate().format(each.date)), format(each.value)]));
|
|
207
|
+
frame.fillLegend([], false);
|
|
208
|
+
paintScale();
|
|
209
|
+
if (selected !== null && selected >= model.days.length) selected = null;
|
|
210
|
+
const same = cells.length === model.days.length && cells.length > 0 && builtFrom === model.days[0]!.key;
|
|
211
|
+
if (same && animate) {
|
|
212
|
+
// New values over the same days: the colours crossfade (CSS).
|
|
213
|
+
cells.forEach((cell, i) => {
|
|
214
|
+
const level = levelOf(model.days[i]!.value);
|
|
215
|
+
cell.setAttribute('data-mk-level', String(level));
|
|
216
|
+
cell.style.fill = fillOf(level);
|
|
217
|
+
});
|
|
218
|
+
} else build(animate);
|
|
219
|
+
paintSelection(false);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const select = (index: number | null, announce = false) => {
|
|
223
|
+
if (scope.disposed) return;
|
|
224
|
+
const next = index === null || !model.days.length ? null : Math.max(0, Math.min(model.days.length - 1, Math.round(index)));
|
|
225
|
+
if (next === selected) return;
|
|
226
|
+
selected = next;
|
|
227
|
+
paintSelection(announce);
|
|
228
|
+
const each = selected === null ? null : model.days[selected]!;
|
|
229
|
+
options.onSelect?.(each?.key ?? null, each?.value ?? null);
|
|
230
|
+
root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, detail: { date: each?.key ?? null, value: each?.value ?? null } }));
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
scope.on(canvas, 'pointermove', (event) => {
|
|
234
|
+
const cell = (event.target as Element).closest('[data-mk-index]');
|
|
235
|
+
select(cell ? Number(cell.getAttribute('data-mk-index')) : null);
|
|
236
|
+
});
|
|
237
|
+
scope.on(canvas, 'pointerleave', (event) => {
|
|
238
|
+
if ((event as PointerEvent).pointerType === 'mouse') select(null);
|
|
239
|
+
});
|
|
240
|
+
scope.on(canvas, 'focus', () => {
|
|
241
|
+
if (selected === null && model.days.length) select(model.days.length - 1, true);
|
|
242
|
+
});
|
|
243
|
+
scope.on(canvas, 'blur', () => select(null));
|
|
244
|
+
scope.on(canvas, 'keydown', (event) => {
|
|
245
|
+
const key = (event as KeyboardEvent).key;
|
|
246
|
+
const at = selected ?? model.days.length - 1;
|
|
247
|
+
const moves: Record<string, number | null> = { ArrowUp: at - 1, ArrowDown: at + 1, ArrowLeft: at - 7, ArrowRight: at + 7, PageUp: at - 28, PageDown: at + 28, Home: 0, End: model.days.length - 1, Escape: null };
|
|
248
|
+
if (!(key in moves) || !model.days.length) return;
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
select(moves[key]!, true);
|
|
251
|
+
// Keep the chosen day in view when the weeks scroll.
|
|
252
|
+
const cell = selected === null ? null : cells[selected];
|
|
253
|
+
if (cell) {
|
|
254
|
+
const x = Number(cell.getAttribute('x'));
|
|
255
|
+
if (x - scroller.scrollLeft < LEFT || x - scroller.scrollLeft > scroller.clientWidth - geometry.cell * 2) scroller.scrollLeft = x - scroller.clientWidth / 2;
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
onWidth(root, scope, () => {
|
|
259
|
+
build(false);
|
|
260
|
+
paintSelection(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
draw(true);
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
update(next) {
|
|
267
|
+
if (scope.disposed) return;
|
|
268
|
+
options = { ...options, ...next };
|
|
269
|
+
model = modelOf();
|
|
270
|
+
draw(true);
|
|
271
|
+
},
|
|
272
|
+
select(date) {
|
|
273
|
+
const index = date === null ? -1 : model.days.findIndex((each) => each.key === date);
|
|
274
|
+
select(index < 0 ? null : index);
|
|
275
|
+
},
|
|
276
|
+
get selected() {
|
|
277
|
+
return selected === null ? null : (model.days[selected]?.key ?? null);
|
|
278
|
+
},
|
|
279
|
+
destroy: () => scope.dispose(),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* heatmap-chart — a week per column of 3 px rounded days, deepening from the track to the accent; needs chart.css.
|
|
2
|
+
The day under the pointer lifts with a ring; the "less … more" scale sits under it, to the right. */
|
|
3
|
+
.mk-heatmap-chart,
|
|
4
|
+
mk-heatmap-chart {
|
|
5
|
+
--mk-heatmap-color: var(--mk-accent);
|
|
6
|
+
--mk-heatmap-empty: var(--mk-surface-2);
|
|
7
|
+
display: grid;
|
|
8
|
+
gap: 0.6rem;
|
|
9
|
+
min-width: 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
[data-mk-heatmap-scroll] {
|
|
13
|
+
overflow-x: auto;
|
|
14
|
+
overflow-y: hidden;
|
|
15
|
+
padding-block: 4px;
|
|
16
|
+
scrollbar-width: thin;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
[data-mk-chart-type='heatmap'] [data-mk-chart-svg] {
|
|
20
|
+
width: var(--mk-heatmap-width, auto);
|
|
21
|
+
max-width: none;
|
|
22
|
+
overflow: visible;
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
[data-mk-chart-type='heatmap'] [data-mk-part='cells'] rect {
|
|
27
|
+
transform-box: fill-box;
|
|
28
|
+
transform-origin: center;
|
|
29
|
+
transition:
|
|
30
|
+
fill 0.35s ease,
|
|
31
|
+
opacity 0.2s ease,
|
|
32
|
+
transform 0.3s var(--mk-ease-swatch);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
[data-mk-chart-type='heatmap'] [data-mk-part='cells'] rect[data-mk-on] {
|
|
36
|
+
stroke: var(--mk-text);
|
|
37
|
+
stroke-width: 1.5;
|
|
38
|
+
transform: scale(1.3);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
[data-mk-chart-type='heatmap'] [data-mk-part='cells'] rect[data-mk-dim] {
|
|
42
|
+
opacity: 0.25;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
[data-mk-chart-type='heatmap'] [data-mk-part='months'] text,
|
|
46
|
+
[data-mk-chart-type='heatmap'] [data-mk-part='weekdays'] text {
|
|
47
|
+
font-size: 10px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
[data-mk-heatmap-scale] {
|
|
51
|
+
display: flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
justify-content: flex-end;
|
|
54
|
+
gap: 3px;
|
|
55
|
+
color: var(--mk-muted);
|
|
56
|
+
font-size: 0.75rem;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
[data-mk-heatmap-scale][hidden] {
|
|
60
|
+
display: none;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
[data-mk-heatmap-scale] span {
|
|
64
|
+
margin-inline: 0.35rem;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
[data-mk-heatmap-scale] i {
|
|
68
|
+
width: 12px;
|
|
69
|
+
height: 12px;
|
|
70
|
+
border-radius: 3px;
|
|
71
|
+
cursor: pointer;
|
|
72
|
+
transition: transform 0.25s var(--mk-ease-swatch);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@media (hover: hover) {
|
|
76
|
+
[data-mk-heatmap-scale] i:hover {
|
|
77
|
+
transform: scale(1.25);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@media (prefers-reduced-motion: reduce) {
|
|
82
|
+
[data-mk-chart-type='heatmap'] [data-mk-part='cells'] rect,
|
|
83
|
+
[data-mk-heatmap-scale] i {
|
|
84
|
+
transition: none;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@media (forced-colors: active) {
|
|
89
|
+
[data-mk-chart-type='heatmap'] [data-mk-part='cells'] rect {
|
|
90
|
+
stroke: CanvasText;
|
|
91
|
+
stroke-width: 0.5;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { HTMLAttributes } from 'react';
|
|
4
|
+
import { useChart } from '../chart/use-chart';
|
|
5
|
+
import { createHeatmapChart, type HeatmapChartInstance, type HeatmapChartOptions } from './engine';
|
|
6
|
+
|
|
7
|
+
export interface HeatmapChartProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect' | 'children'>, HeatmapChartOptions {
|
|
8
|
+
instanceRef?: { current: HeatmapChartInstance | null };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function HeatmapChart({ data, end, weeks, firstDay, locale, levels, color, unit, label, legend, formatValue, emptyLabel, onSelect, instanceRef, className, ...props }: HeatmapChartProps) {
|
|
12
|
+
const ref = useChart(createHeatmapChart, { data, end, weeks, firstDay, locale, levels, color, unit, label, legend, emptyLabel }, { formatValue, onSelect }, instanceRef);
|
|
13
|
+
return <div {...props} ref={ref} className={['mk-heatmap-chart', className].filter(Boolean).join(' ')} />;
|
|
14
|
+
}
|