@yewdiel21/motion-kit 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.md +178 -155
  2. package/bin/motion-kit.mjs +3 -3
  3. package/dist/auto/index.d.ts +6 -3
  4. package/dist/auto/index.js +8 -2
  5. package/dist/auto/index.js.map +1 -1
  6. package/dist/{chunk-EY4BJKC4.js → chunk-NFZIX3IA.js} +2 -2
  7. package/dist/chunk-RBEKR5UZ.js +3660 -0
  8. package/dist/chunk-RBEKR5UZ.js.map +1 -0
  9. package/dist/{chunk-CU6SXW3E.js → chunk-SDQ776LG.js} +1596 -485
  10. package/dist/chunk-SDQ776LG.js.map +1 -0
  11. package/dist/css/components/add-to-cart.css +72 -0
  12. package/dist/css/components/bidirectional-slider.css +168 -0
  13. package/dist/css/components/chart.css +7 -0
  14. package/dist/css/components/data-table.css +121 -26
  15. package/dist/css/components/filter-builder.css +10 -15
  16. package/dist/css/components/heatmap-chart.css +398 -0
  17. package/dist/css/components/hover-card.css +8 -2
  18. package/dist/css/components/image-preview.css +90 -2
  19. package/dist/css/components/json-viewer.css +33 -1
  20. package/dist/css/components/nav.css +102 -102
  21. package/dist/css/components/orbit-menu.css +130 -0
  22. package/dist/css/components/pagination.css +10 -5
  23. package/dist/css/components/pie-chart.css +397 -0
  24. package/dist/css/components/pill-button.css +135 -0
  25. package/dist/css/components/radar-chart.css +377 -0
  26. package/dist/css/components/radial-chart.css +412 -0
  27. package/dist/css/components/sankey-chart.css +365 -0
  28. package/dist/css/components/scroll-island.css +114 -0
  29. package/dist/css/components/select.css +136 -0
  30. package/dist/css/components/toolbar.css +122 -0
  31. package/dist/css/components/tree-view.css +79 -0
  32. package/dist/css/components/voice-dots.css +70 -0
  33. package/dist/elements/index.d.ts +180 -7
  34. package/dist/elements/index.js +293 -7
  35. package/dist/elements/index.js.map +1 -1
  36. package/dist/{engine-Cn6IhE3z.d.ts → engine-5zRIssTO.d.ts} +11 -1
  37. package/dist/index.css +1849 -168
  38. package/dist/index.d.ts +4 -4
  39. package/dist/index.js +33 -3
  40. package/dist/index.js.map +1 -1
  41. package/dist/react/index.d.ts +117 -11
  42. package/dist/react/index.js +320 -13
  43. package/dist/react/index.js.map +1 -1
  44. package/dist/{reduced-BBUusld6.d.ts → reduced-DQwVXQYb.d.ts} +1 -1
  45. package/dist/{reveal-DEvp6U6T.d.ts → reveal-DRpNO4KP.d.ts} +109 -4
  46. package/dist/store-Da3zSh8Z.d.ts +457 -0
  47. package/package.json +9 -16
  48. package/registry.json +1096 -948
  49. package/src/auto/auto-init.ts +6 -0
  50. package/src/components/add-to-cart/README.md +49 -0
  51. package/src/components/add-to-cart/add-to-cart.css +71 -0
  52. package/src/components/add-to-cart/element.ts +20 -0
  53. package/src/components/add-to-cart/engine.ts +206 -0
  54. package/src/components/add-to-cart/react.tsx +33 -0
  55. package/src/components/bidirectional-slider/README.md +46 -0
  56. package/src/components/bidirectional-slider/bidirectional-slider.css +167 -0
  57. package/src/components/bidirectional-slider/element.ts +42 -0
  58. package/src/components/bidirectional-slider/engine.ts +289 -0
  59. package/src/components/bidirectional-slider/react.tsx +45 -0
  60. package/src/components/chart/README.md +49 -24
  61. package/src/components/chart/chart.css +7 -0
  62. package/src/components/chart/element.ts +4 -1
  63. package/src/components/chart/engine.ts +153 -34
  64. package/src/components/chart/parts.ts +237 -0
  65. package/src/components/chart/react.tsx +2 -2
  66. package/src/components/chart/use-chart.ts +55 -0
  67. package/src/components/data-table/README.md +3 -3
  68. package/src/components/data-table/data-table.css +121 -26
  69. package/src/components/data-table/engine.ts +81 -29
  70. package/src/components/editable-table/engine.ts +67 -11
  71. package/src/components/filter-builder/engine.ts +33 -2
  72. package/src/components/filter-builder/filter-builder.css +10 -15
  73. package/src/components/heatmap-chart/README.md +48 -0
  74. package/src/components/heatmap-chart/element.ts +21 -0
  75. package/src/components/heatmap-chart/engine.ts +281 -0
  76. package/src/components/heatmap-chart/heatmap-chart.css +93 -0
  77. package/src/components/heatmap-chart/react.tsx +14 -0
  78. package/src/components/hover-card/engine.ts +30 -1
  79. package/src/components/hover-card/hover-card.css +8 -2
  80. package/src/components/image-preview/element.ts +1 -1
  81. package/src/components/image-preview/engine.ts +95 -15
  82. package/src/components/image-preview/image-preview.css +90 -2
  83. package/src/components/image-preview/react.tsx +3 -3
  84. package/src/components/json-viewer/engine.ts +11 -2
  85. package/src/components/json-viewer/json-viewer.css +33 -1
  86. package/src/components/nav/README.md +12 -10
  87. package/src/components/nav/element.ts +5 -5
  88. package/src/components/nav/engine.ts +21 -14
  89. package/src/components/nav/nav.css +102 -102
  90. package/src/components/nav/react.tsx +4 -4
  91. package/src/components/orbit-menu/README.md +46 -0
  92. package/src/components/orbit-menu/element.ts +27 -0
  93. package/src/components/orbit-menu/engine.ts +179 -0
  94. package/src/components/orbit-menu/orbit-menu.css +129 -0
  95. package/src/components/orbit-menu/react.tsx +44 -0
  96. package/src/components/pagination/README.md +1 -1
  97. package/src/components/pagination/engine.ts +50 -4
  98. package/src/components/pagination/pagination.css +10 -5
  99. package/src/components/pie-chart/README.md +48 -0
  100. package/src/components/pie-chart/element.ts +30 -0
  101. package/src/components/pie-chart/engine.ts +283 -0
  102. package/src/components/pie-chart/pie-chart.css +92 -0
  103. package/src/components/pie-chart/react.tsx +14 -0
  104. package/src/components/pill-button/README.md +46 -0
  105. package/src/components/pill-button/element.ts +16 -0
  106. package/src/components/pill-button/engine.ts +77 -0
  107. package/src/components/pill-button/pill-button.css +134 -0
  108. package/src/components/pill-button/react.tsx +32 -0
  109. package/src/components/radar-chart/README.md +48 -0
  110. package/src/components/radar-chart/element.ts +30 -0
  111. package/src/components/radar-chart/engine.ts +327 -0
  112. package/src/components/radar-chart/radar-chart.css +72 -0
  113. package/src/components/radar-chart/react.tsx +14 -0
  114. package/src/components/radial-chart/README.md +58 -0
  115. package/src/components/radial-chart/element.ts +30 -0
  116. package/src/components/radial-chart/engine.ts +317 -0
  117. package/src/components/radial-chart/radial-chart.css +107 -0
  118. package/src/components/radial-chart/react.tsx +14 -0
  119. package/src/components/sankey-chart/README.md +48 -0
  120. package/src/components/sankey-chart/element.ts +31 -0
  121. package/src/components/sankey-chart/engine.ts +401 -0
  122. package/src/components/sankey-chart/react.tsx +14 -0
  123. package/src/components/sankey-chart/sankey-chart.css +60 -0
  124. package/src/components/scroll-island/README.md +44 -0
  125. package/src/components/scroll-island/element.ts +16 -0
  126. package/src/components/scroll-island/engine.ts +274 -0
  127. package/src/components/scroll-island/react.tsx +26 -0
  128. package/src/components/scroll-island/scroll-island.css +113 -0
  129. package/src/components/select/README.md +34 -0
  130. package/src/components/select/element.ts +26 -0
  131. package/src/components/select/engine.ts +267 -0
  132. package/src/components/select/react.tsx +33 -0
  133. package/src/components/select/select.css +135 -0
  134. package/src/components/toolbar/README.md +46 -0
  135. package/src/components/toolbar/element.ts +30 -0
  136. package/src/components/toolbar/engine.ts +230 -0
  137. package/src/components/toolbar/react.tsx +34 -0
  138. package/src/components/toolbar/toolbar.css +121 -0
  139. package/src/components/tree-view/element.ts +1 -1
  140. package/src/components/tree-view/engine.ts +174 -0
  141. package/src/components/tree-view/react.tsx +7 -3
  142. package/src/components/tree-view/tree-view.css +79 -0
  143. package/src/components/voice-dots/README.md +48 -0
  144. package/src/components/voice-dots/element.ts +45 -0
  145. package/src/components/voice-dots/engine.ts +243 -0
  146. package/src/components/voice-dots/react.tsx +52 -0
  147. package/src/components/voice-dots/voice-dots.css +69 -0
  148. package/src/elements/index.ts +39 -0
  149. package/src/index.ts +16 -0
  150. package/src/motion/liquid.ts +14 -4
  151. package/src/react/index.ts +14 -1
  152. package/dist/chunk-6P4P7NU6.js +0 -1401
  153. package/dist/chunk-6P4P7NU6.js.map +0 -1
  154. package/dist/chunk-CU6SXW3E.js.map +0 -1
  155. package/dist/store-DjkTBX7f.d.ts +0 -195
  156. /package/dist/{chunk-EY4BJKC4.js.map → chunk-NFZIX3IA.js.map} +0 -0
@@ -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
- [data-mk-fb-rule] :is(select, input) {
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] :is(select, input):focus-visible {
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] :is(select, input) {
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
+ }