@misoto22/design 0.5.0 → 0.6.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 (214) hide show
  1. package/README.md +124 -0
  2. package/bin/misoto22-design.mjs +205 -0
  3. package/dist/agent/Accordion.md +41 -0
  4. package/dist/agent/Alert.md +42 -0
  5. package/dist/agent/AppShell.md +40 -0
  6. package/dist/agent/AreaChart.md +68 -0
  7. package/dist/agent/Article.md +41 -0
  8. package/dist/agent/Avatar.md +32 -0
  9. package/dist/agent/Badge.md +40 -0
  10. package/dist/agent/BarChart.md +65 -0
  11. package/dist/agent/BarList.md +40 -0
  12. package/dist/agent/BigNumber.md +45 -0
  13. package/dist/agent/BoxPlot.md +58 -0
  14. package/dist/agent/Breadcrumb.md +32 -0
  15. package/dist/agent/BulletChart.md +48 -0
  16. package/dist/agent/Button.md +56 -0
  17. package/dist/agent/Calendar.md +42 -0
  18. package/dist/agent/Card.md +49 -0
  19. package/dist/agent/Checkbox.md +35 -0
  20. package/dist/agent/Collapsible.md +46 -0
  21. package/dist/agent/Combobox.md +50 -0
  22. package/dist/agent/Command.md +93 -0
  23. package/dist/agent/ComposedChart.md +57 -0
  24. package/dist/agent/ContextMenu.md +59 -0
  25. package/dist/agent/DatePicker.md +75 -0
  26. package/dist/agent/Diagram.md +46 -0
  27. package/dist/agent/Dialog.md +52 -0
  28. package/dist/agent/DropdownMenu.md +70 -0
  29. package/dist/agent/EmptyState.md +37 -0
  30. package/dist/agent/ErrorState.md +41 -0
  31. package/dist/agent/Facet.md +82 -0
  32. package/dist/agent/Field.md +37 -0
  33. package/dist/agent/FigureBand.md +37 -0
  34. package/dist/agent/FloatingIconButton.md +38 -0
  35. package/dist/agent/FunnelChart.md +52 -0
  36. package/dist/agent/Heatmap.md +53 -0
  37. package/dist/agent/Histogram.md +63 -0
  38. package/dist/agent/Input.md +30 -0
  39. package/dist/agent/Kbd.md +29 -0
  40. package/dist/agent/LineChart.md +60 -0
  41. package/dist/agent/LinkArrow.md +29 -0
  42. package/dist/agent/NativeSelect.md +40 -0
  43. package/dist/agent/NavItem.md +35 -0
  44. package/dist/agent/Pagination.md +43 -0
  45. package/dist/agent/PieChart.md +55 -0
  46. package/dist/agent/Popover.md +57 -0
  47. package/dist/agent/Progress.md +33 -0
  48. package/dist/agent/RadarChart.md +54 -0
  49. package/dist/agent/RadialChart.md +57 -0
  50. package/dist/agent/RadioGroup.md +44 -0
  51. package/dist/agent/SankeyChart.md +61 -0
  52. package/dist/agent/ScatterChart.md +54 -0
  53. package/dist/agent/ScrollArea.md +40 -0
  54. package/dist/agent/SearchableMenu.md +46 -0
  55. package/dist/agent/Select.md +75 -0
  56. package/dist/agent/Separator.md +35 -0
  57. package/dist/agent/Sheet.md +57 -0
  58. package/dist/agent/Skeleton.md +73 -0
  59. package/dist/agent/Slider.md +39 -0
  60. package/dist/agent/Sparkline.md +45 -0
  61. package/dist/agent/Spinner.md +39 -0
  62. package/dist/agent/StatusDot.md +36 -0
  63. package/dist/agent/StatusPill.md +28 -0
  64. package/dist/agent/Steps.md +44 -0
  65. package/dist/agent/Switch.md +29 -0
  66. package/dist/agent/Table.md +85 -0
  67. package/dist/agent/Tabs.md +48 -0
  68. package/dist/agent/Tag.md +35 -0
  69. package/dist/agent/Textarea.md +26 -0
  70. package/dist/agent/Toast.md +26 -0
  71. package/dist/agent/ToggleGroup.md +44 -0
  72. package/dist/agent/Tooltip.md +51 -0
  73. package/dist/agent/TreemapChart.md +52 -0
  74. package/dist/agent/WaterfallChart.md +62 -0
  75. package/dist/agent/catalog.json +1791 -0
  76. package/dist/agent/index.md +131 -0
  77. package/dist/charts/AreaChart/AreaChart.d.ts +191 -0
  78. package/dist/charts/AreaChart/AreaChart.js +480 -0
  79. package/dist/charts/AreaChart/AreaChart.js.map +1 -0
  80. package/dist/charts/BarChart/BarChart.d.ts +179 -0
  81. package/dist/charts/BarChart/BarChart.js +591 -0
  82. package/dist/charts/BarChart/BarChart.js.map +1 -0
  83. package/dist/charts/BarList/BarList.d.ts +57 -0
  84. package/dist/charts/BarList/BarList.js +71 -0
  85. package/dist/charts/BarList/BarList.js.map +1 -0
  86. package/dist/charts/BigNumber/BigNumber.d.ts +56 -0
  87. package/dist/charts/BigNumber/BigNumber.js +31 -0
  88. package/dist/charts/BigNumber/BigNumber.js.map +1 -0
  89. package/dist/charts/BoxPlot/BoxPlot.d.ts +177 -0
  90. package/dist/charts/BoxPlot/BoxPlot.js +495 -0
  91. package/dist/charts/BoxPlot/BoxPlot.js.map +1 -0
  92. package/dist/charts/BulletChart/BulletChart.d.ts +100 -0
  93. package/dist/charts/BulletChart/BulletChart.js +162 -0
  94. package/dist/charts/BulletChart/BulletChart.js.map +1 -0
  95. package/dist/charts/ComposedChart/ComposedChart.d.ts +171 -0
  96. package/dist/charts/ComposedChart/ComposedChart.js +667 -0
  97. package/dist/charts/ComposedChart/ComposedChart.js.map +1 -0
  98. package/dist/charts/Facet/Facet.d.ts +240 -0
  99. package/dist/charts/Facet/Facet.js +133 -0
  100. package/dist/charts/Facet/Facet.js.map +1 -0
  101. package/dist/charts/Facet/panels.d.ts +187 -0
  102. package/dist/charts/Facet/panels.js +203 -0
  103. package/dist/charts/Facet/panels.js.map +1 -0
  104. package/dist/charts/FunnelChart/FunnelChart.d.ts +101 -0
  105. package/dist/charts/FunnelChart/FunnelChart.js +147 -0
  106. package/dist/charts/FunnelChart/FunnelChart.js.map +1 -0
  107. package/dist/charts/Heatmap/Heatmap.d.ts +78 -0
  108. package/dist/charts/Heatmap/Heatmap.js +108 -0
  109. package/dist/charts/Heatmap/Heatmap.js.map +1 -0
  110. package/dist/charts/Histogram/Histogram.d.ts +165 -0
  111. package/dist/charts/Histogram/Histogram.js +350 -0
  112. package/dist/charts/Histogram/Histogram.js.map +1 -0
  113. package/dist/charts/LineChart/LineChart.d.ts +167 -0
  114. package/dist/charts/LineChart/LineChart.js +486 -0
  115. package/dist/charts/LineChart/LineChart.js.map +1 -0
  116. package/dist/charts/PieChart/PieChart.d.ts +128 -0
  117. package/dist/charts/PieChart/PieChart.js +309 -0
  118. package/dist/charts/PieChart/PieChart.js.map +1 -0
  119. package/dist/charts/RadarChart/RadarChart.d.ts +123 -0
  120. package/dist/charts/RadarChart/RadarChart.js +299 -0
  121. package/dist/charts/RadarChart/RadarChart.js.map +1 -0
  122. package/dist/charts/RadialChart/RadialChart.d.ts +124 -0
  123. package/dist/charts/RadialChart/RadialChart.js +257 -0
  124. package/dist/charts/RadialChart/RadialChart.js.map +1 -0
  125. package/dist/charts/SankeyChart/SankeyChart.d.ts +137 -0
  126. package/dist/charts/SankeyChart/SankeyChart.js +501 -0
  127. package/dist/charts/SankeyChart/SankeyChart.js.map +1 -0
  128. package/dist/charts/ScatterChart/ScatterChart.d.ts +128 -0
  129. package/dist/charts/ScatterChart/ScatterChart.js +269 -0
  130. package/dist/charts/ScatterChart/ScatterChart.js.map +1 -0
  131. package/dist/charts/Sparkline/Sparkline.d.ts +53 -0
  132. package/dist/charts/Sparkline/Sparkline.js +93 -0
  133. package/dist/charts/Sparkline/Sparkline.js.map +1 -0
  134. package/dist/charts/TreemapChart/TreemapChart.d.ts +93 -0
  135. package/dist/charts/TreemapChart/TreemapChart.js +118 -0
  136. package/dist/charts/TreemapChart/TreemapChart.js.map +1 -0
  137. package/dist/charts/WaterfallChart/WaterfallChart.d.ts +164 -0
  138. package/dist/charts/WaterfallChart/WaterfallChart.js +319 -0
  139. package/dist/charts/WaterfallChart/WaterfallChart.js.map +1 -0
  140. package/dist/charts/index.d.ts +41 -0
  141. package/dist/charts/index.js +128 -0
  142. package/dist/charts/index.js.map +1 -0
  143. package/dist/charts/lib/annotations.d.ts +103 -0
  144. package/dist/charts/lib/annotations.js +99 -0
  145. package/dist/charts/lib/annotations.js.map +1 -0
  146. package/dist/charts/lib/axis.d.ts +18 -0
  147. package/dist/charts/lib/axis.js +21 -0
  148. package/dist/charts/lib/axis.js.map +1 -0
  149. package/dist/charts/lib/background.d.ts +33 -0
  150. package/dist/charts/lib/background.js +173 -0
  151. package/dist/charts/lib/background.js.map +1 -0
  152. package/dist/charts/lib/brush.d.ts +94 -0
  153. package/dist/charts/lib/brush.js +535 -0
  154. package/dist/charts/lib/brush.js.map +1 -0
  155. package/dist/charts/lib/chart.d.ts +94 -0
  156. package/dist/charts/lib/chart.js +134 -0
  157. package/dist/charts/lib/chart.js.map +1 -0
  158. package/dist/charts/lib/dot.d.ts +29 -0
  159. package/dist/charts/lib/dot.js +78 -0
  160. package/dist/charts/lib/dot.js.map +1 -0
  161. package/dist/charts/lib/empty.d.ts +31 -0
  162. package/dist/charts/lib/empty.js +13 -0
  163. package/dist/charts/lib/empty.js.map +1 -0
  164. package/dist/charts/lib/export.d.ts +103 -0
  165. package/dist/charts/lib/export.js +204 -0
  166. package/dist/charts/lib/export.js.map +1 -0
  167. package/dist/charts/lib/figure.d.ts +61 -0
  168. package/dist/charts/lib/figure.js +59 -0
  169. package/dist/charts/lib/figure.js.map +1 -0
  170. package/dist/charts/lib/format.d.ts +46 -0
  171. package/dist/charts/lib/format.js +56 -0
  172. package/dist/charts/lib/format.js.map +1 -0
  173. package/dist/charts/lib/legend.d.ts +45 -0
  174. package/dist/charts/lib/legend.js +117 -0
  175. package/dist/charts/lib/legend.js.map +1 -0
  176. package/dist/charts/lib/loading.d.ts +48 -0
  177. package/dist/charts/lib/loading.js +105 -0
  178. package/dist/charts/lib/loading.js.map +1 -0
  179. package/dist/charts/lib/paint.d.ts +154 -0
  180. package/dist/charts/lib/paint.js +551 -0
  181. package/dist/charts/lib/paint.js.map +1 -0
  182. package/dist/charts/lib/sonify-control.d.ts +163 -0
  183. package/dist/charts/lib/sonify-control.js +172 -0
  184. package/dist/charts/lib/sonify-control.js.map +1 -0
  185. package/dist/charts/lib/sonify.d.ts +248 -0
  186. package/dist/charts/lib/sonify.js +262 -0
  187. package/dist/charts/lib/sonify.js.map +1 -0
  188. package/dist/charts/lib/toolbar.d.ts +143 -0
  189. package/dist/charts/lib/toolbar.js +156 -0
  190. package/dist/charts/lib/toolbar.js.map +1 -0
  191. package/dist/charts/lib/tooltip.d.ts +59 -0
  192. package/dist/charts/lib/tooltip.js +135 -0
  193. package/dist/charts/lib/tooltip.js.map +1 -0
  194. package/dist/charts/lib/values.d.ts +56 -0
  195. package/dist/charts/lib/values.js +71 -0
  196. package/dist/charts/lib/values.js.map +1 -0
  197. package/dist/charts/lib/zoom.d.ts +139 -0
  198. package/dist/charts/lib/zoom.js +314 -0
  199. package/dist/charts/lib/zoom.js.map +1 -0
  200. package/dist/semantic.css +19 -0
  201. package/dist/styles.css +1 -1
  202. package/dist/themes.css +50 -0
  203. package/dist/tokens.css +118 -0
  204. package/dist/tokens.d.ts +17 -0
  205. package/dist/tokens.js +101 -0
  206. package/dist/tokens.json +101 -0
  207. package/package.json +26 -4
  208. package/skills/misoto22-design/SKILL.md +171 -0
  209. package/skills/misoto22-design/evals/claims.json +95 -0
  210. package/skills/misoto22-design/rules/a11y.md +115 -0
  211. package/skills/misoto22-design/rules/composition.md +108 -0
  212. package/skills/misoto22-design/rules/forms.md +128 -0
  213. package/skills/misoto22-design/rules/naming.md +155 -0
  214. package/skills/misoto22-design/rules/tokens.md +131 -0
@@ -0,0 +1,262 @@
1
+ import { defaultTick } from "./format.js";
2
+ const SONIFY_DEFAULTS = {
3
+ noteMs: 220,
4
+ gapMs: 700,
5
+ leadInMs: 1200,
6
+ rootFrequency: 220,
7
+ semitones: 24,
8
+ wave: "triangle",
9
+ volume: 0.18
10
+ };
11
+ const ATTACK_SECONDS = 8e-3;
12
+ const RELEASE_SECONDS = 0.06;
13
+ const NOTE_DUTY = 0.85;
14
+ const LOOKAHEAD_SECONDS = 0.02;
15
+ const CUT_SECONDS = 0.015;
16
+ function valueToFrequency(value, domain, pitch = {}) {
17
+ const root = pitch.rootFrequency ?? SONIFY_DEFAULTS.rootFrequency;
18
+ const semitones = pitch.semitones ?? SONIFY_DEFAULTS.semitones;
19
+ const span = domain.max - domain.min;
20
+ const position = span === 0 ? 0.5 : (value - domain.min) / span;
21
+ const clamped = Math.min(1, Math.max(0, position));
22
+ return root * 2 ** (clamped * semitones / 12);
23
+ }
24
+ function sonifyDomain(series) {
25
+ let min = Number.POSITIVE_INFINITY;
26
+ let max = Number.NEGATIVE_INFINITY;
27
+ for (const entry of series) {
28
+ for (const value of entry.values) {
29
+ if (value === null || !Number.isFinite(value)) continue;
30
+ if (value < min) min = value;
31
+ if (value > max) max = value;
32
+ }
33
+ }
34
+ return min === Number.POSITIVE_INFINITY ? null : { min, max };
35
+ }
36
+ function describeSeries(series, formatValue = defaultTick) {
37
+ const finite = series.values.filter((value) => value !== null && Number.isFinite(value));
38
+ if (finite.length === 0) return `${series.label}, no data.`;
39
+ const categories = series.categories ?? [];
40
+ const first = categories[0];
41
+ const last = categories[categories.length - 1];
42
+ const span = first !== void 0 && last !== void 0 && first !== last ? `, ${first} to ${last}` : "";
43
+ const low = formatValue(Math.min(...finite));
44
+ const high = formatValue(Math.max(...finite));
45
+ const gaps = series.values.length - finite.length;
46
+ const missing = gaps > 0 ? ` ${gaps} missing.` : "";
47
+ return `${series.label}, ${series.values.length} points${span}. Values from ${low} to ${high}, low pitch to high pitch.${missing}`;
48
+ }
49
+ function sonifyTimeline(series, options = {}) {
50
+ const noteMs = options.noteMs ?? SONIFY_DEFAULTS.noteMs;
51
+ const gapMs = options.gapMs ?? SONIFY_DEFAULTS.gapMs;
52
+ const leadInMs = options.leadInMs ?? SONIFY_DEFAULTS.leadInMs;
53
+ const formatValue = options.formatValue ?? defaultTick;
54
+ const domain = sonifyDomain(series);
55
+ const steps = [];
56
+ let at = 0;
57
+ series.forEach((entry, entryIndex) => {
58
+ if (entryIndex > 0) at += gapMs;
59
+ steps.push({ kind: "announce", atMs: at, text: describeSeries(entry, formatValue) });
60
+ at += leadInMs;
61
+ entry.values.forEach((raw, index) => {
62
+ const value = raw !== null && Number.isFinite(raw) ? raw : null;
63
+ steps.push({
64
+ kind: "note",
65
+ atMs: at,
66
+ point: {
67
+ seriesKey: entry.key,
68
+ seriesLabel: entry.label,
69
+ index,
70
+ category: entry.categories?.[index],
71
+ value
72
+ },
73
+ // A gap keeps its slot and sounds as a rest. Closing it up would move
74
+ // every later point earlier and quietly redraw the shape.
75
+ frequency: value === null || domain === null ? null : valueToFrequency(value, domain, options)
76
+ });
77
+ at += noteMs;
78
+ });
79
+ });
80
+ return steps;
81
+ }
82
+ function sonifyDuration(series, options = {}) {
83
+ const steps = sonifyTimeline(series, options);
84
+ const last = steps[steps.length - 1];
85
+ if (!last) return 0;
86
+ return last.kind === "note" ? last.atMs + (options.noteMs ?? SONIFY_DEFAULTS.noteMs) : last.atMs;
87
+ }
88
+ function hasAudioSupport() {
89
+ if (typeof window === "undefined") return false;
90
+ return audioContextConstructor() !== void 0;
91
+ }
92
+ function audioContextConstructor() {
93
+ if (typeof window === "undefined") return void 0;
94
+ const scope = window;
95
+ return scope.AudioContext ?? scope.webkitAudioContext;
96
+ }
97
+ function createSonification({
98
+ series,
99
+ options = {},
100
+ onPoint,
101
+ onAnnounce,
102
+ onStateChange,
103
+ onEnd
104
+ }) {
105
+ const steps = sonifyTimeline(series, options);
106
+ const durationMs = sonifyDuration(series, options);
107
+ const wave = options.wave ?? SONIFY_DEFAULTS.wave;
108
+ const volume = options.volume ?? SONIFY_DEFAULTS.volume;
109
+ const noteMs = options.noteMs ?? SONIFY_DEFAULTS.noteMs;
110
+ let state = "idle";
111
+ let cursor = 0;
112
+ let elapsedMs = 0;
113
+ let anchorMs = 0;
114
+ let timer = null;
115
+ let context = null;
116
+ let master = null;
117
+ const live = /* @__PURE__ */ new Set();
118
+ function setState(next) {
119
+ if (state === next) return;
120
+ state = next;
121
+ onStateChange?.(next);
122
+ }
123
+ function audio() {
124
+ if (context && master) return { context, master };
125
+ const Constructor = audioContextConstructor();
126
+ if (!Constructor) return null;
127
+ context = new Constructor();
128
+ master = context.createGain();
129
+ master.gain.value = volume;
130
+ master.connect(context.destination);
131
+ return { context, master };
132
+ }
133
+ function sound(frequency, at) {
134
+ const engine = audio();
135
+ if (!engine) return;
136
+ const body = Math.max(ATTACK_SECONDS + RELEASE_SECONDS, noteMs / 1e3 * NOTE_DUTY);
137
+ const oscillator = engine.context.createOscillator();
138
+ const gain = engine.context.createGain();
139
+ oscillator.type = wave;
140
+ oscillator.frequency.setValueAtTime(frequency, at);
141
+ gain.gain.setValueAtTime(0, at);
142
+ gain.gain.linearRampToValueAtTime(1, at + ATTACK_SECONDS);
143
+ gain.gain.setValueAtTime(1, at + body - RELEASE_SECONDS);
144
+ gain.gain.linearRampToValueAtTime(0, at + body);
145
+ oscillator.connect(gain);
146
+ gain.connect(engine.master);
147
+ oscillator.start(at);
148
+ oscillator.stop(at + body);
149
+ const node = { oscillator, gain };
150
+ live.add(node);
151
+ oscillator.onended = () => {
152
+ live.delete(node);
153
+ gain.disconnect();
154
+ oscillator.disconnect();
155
+ };
156
+ }
157
+ function silence() {
158
+ if (!context) return;
159
+ const now = context.currentTime;
160
+ for (const node of live) {
161
+ node.gain.gain.cancelScheduledValues(now);
162
+ node.gain.gain.setValueAtTime(node.gain.gain.value, now);
163
+ node.gain.gain.linearRampToValueAtTime(0, now + CUT_SECONDS);
164
+ node.oscillator.stop(now + CUT_SECONDS);
165
+ }
166
+ }
167
+ function clearTimer() {
168
+ if (timer === null) return;
169
+ clearTimeout(timer);
170
+ timer = null;
171
+ }
172
+ function finish() {
173
+ clearTimer();
174
+ cursor = 0;
175
+ elapsedMs = 0;
176
+ setState("idle");
177
+ onPoint?.(null);
178
+ onEnd?.();
179
+ }
180
+ function tick() {
181
+ timer = null;
182
+ const step = steps[cursor];
183
+ if (!step) {
184
+ finish();
185
+ return;
186
+ }
187
+ cursor += 1;
188
+ if (step.kind === "announce") {
189
+ onPoint?.(null);
190
+ onAnnounce?.(step.text);
191
+ } else {
192
+ onPoint?.(step.point);
193
+ if (step.frequency !== null && context) {
194
+ sound(step.frequency, context.currentTime + LOOKAHEAD_SECONDS);
195
+ }
196
+ }
197
+ schedule();
198
+ }
199
+ function schedule() {
200
+ const next = steps[cursor];
201
+ const target = next ? next.atMs : durationMs;
202
+ const wait = Math.max(0, target - (Date.now() - anchorMs));
203
+ timer = setTimeout(tick, wait);
204
+ }
205
+ function play() {
206
+ if (state === "playing") return;
207
+ const engine = audio();
208
+ if (engine && engine.context.state === "suspended") void engine.context.resume();
209
+ anchorMs = Date.now() - elapsedMs;
210
+ setState("playing");
211
+ schedule();
212
+ }
213
+ function pause() {
214
+ if (state !== "playing") return;
215
+ elapsedMs = Date.now() - anchorMs;
216
+ clearTimer();
217
+ silence();
218
+ setState("paused");
219
+ }
220
+ function stop() {
221
+ clearTimer();
222
+ silence();
223
+ cursor = 0;
224
+ elapsedMs = 0;
225
+ setState("idle");
226
+ onPoint?.(null);
227
+ }
228
+ function destroy() {
229
+ clearTimer();
230
+ silence();
231
+ cursor = 0;
232
+ elapsedMs = 0;
233
+ state = "idle";
234
+ void context?.close();
235
+ context = null;
236
+ master = null;
237
+ live.clear();
238
+ }
239
+ return {
240
+ play,
241
+ pause,
242
+ stop,
243
+ destroy,
244
+ get state() {
245
+ return state;
246
+ },
247
+ get durationMs() {
248
+ return durationMs;
249
+ }
250
+ };
251
+ }
252
+ export {
253
+ SONIFY_DEFAULTS,
254
+ createSonification,
255
+ describeSeries,
256
+ hasAudioSupport,
257
+ sonifyDomain,
258
+ sonifyDuration,
259
+ sonifyTimeline,
260
+ valueToFrequency
261
+ };
262
+ //# sourceMappingURL=sonify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/charts/lib/sonify.ts"],"sourcesContent":["import { defaultTick } from './format'\n\n/**\n * A chart's values as pitch over time — the reading a listener gets instead of\n * the picture.\n *\n * The hidden data table (see `figure.tsx`) hands a screen-reader user every\n * number, which is access but not a SHAPE: fourteen figures read one at a time\n * do not tell anyone that the line dips in March and never recovers. A run of\n * tones does, in three seconds, and it is the one thing a chart can offer that\n * a table cannot. Highcharts is the only mainstream library that ships it; this\n * is that idea, without the dependency.\n *\n * Nothing here touches `window` or constructs an `AudioContext` at module load.\n * The context is created on the first `play()`, which is the only moment a\n * browser will allow it — every engine blocks audio until a user gesture, and a\n * context built at import time arrives permanently `suspended`.\n *\n * **This never starts on its own.** `prefers-reduced-motion` is not the setting\n * that governs it — sound is not motion, and gating audio behind a motion\n * preference would both silence readers who want it and imply that readers who\n * do not want motion also do not want sound. The correct rule is simpler and\n * absolute: audio begins from an explicit user action, and from nothing else.\n * There is deliberately no `autoPlay` option to pass.\n */\n\n/** Whether the run is sounding, held, or not started. */\nexport type SonifyState = 'idle' | 'playing' | 'paused'\n\n/**\n * The oscillator behind every note.\n *\n * `triangle` is the default because the two obvious choices are both worse: a\n * `sine` has no harmonics above its fundamental, so on a laptop speaker — which\n * reproduces almost nothing under 200 Hz — the bottom of the range simply\n * vanishes, and a listener hears silence where the data is lowest. A `square`\n * is the opposite problem: all odd harmonics at full strength is fatiguing\n * inside ten notes and genuinely unpleasant across sixty. A triangle carries\n * enough harmonic content to survive a small speaker and stay comfortable.\n */\nexport type SonifyWave = 'sine' | 'triangle' | 'square' | 'sawtooth'\n\n/** The value range the pitch scale is stretched across. */\nexport interface SonifyDomain {\n min: number\n max: number\n}\n\n/** One series, flattened to what sound needs: its values, in order. */\nexport interface SonifySeries {\n /** The series key, so a caller can tie a point back to its own data. */\n key: string\n /** What the series is called, spoken before its run of tones. */\n label: string\n /** One entry per point. `null` is a gap — it holds its slot in silence. */\n values: (number | null)[]\n /** The category axis, if there is one. Used to say where the run starts and ends. */\n categories?: string[]\n}\n\n/** The point sounding right now, for a caller that wants to highlight it. */\nexport interface SonifyPoint {\n seriesKey: string\n seriesLabel: string\n /** Position within its own series. */\n index: number\n /** The category this point sits at, when the chart has a category axis. */\n category?: string\n /** `null` for a gap in the data, which sounds as a rest. */\n value: number | null\n}\n\nexport interface SonifyOptions {\n /** How long each point holds the floor. Below ~90ms the run stops being readable. */\n noteMs?: number\n /** Silence after a series, before the next one is announced. */\n gapMs?: number\n /**\n * The pause between an announcement and the first note it describes.\n *\n * Not decoration: a screen reader needs the floor to itself to read the range\n * out, and a tone starting on top of it means the listener gets neither.\n */\n leadInMs?: number\n /** The pitch the lowest value sounds at, in Hz. */\n rootFrequency?: number\n /** How far above the root the highest value sits, in semitones. */\n semitones?: number\n wave?: SonifyWave\n /** Peak gain per note, 0–1. Deliberately quiet; a chart is not a media player. */\n volume?: number\n /**\n * How a number is spoken in an announcement. Defaults to the axis's own\n * formatting.\n *\n * Define it outside the render or memoise it. It is part of what the run\n * says, so a new function identity rebuilds the run — and a rebuilt run is a\n * stopped one.\n */\n formatValue?: (value: number) => string\n}\n\n/**\n * The defaults, and why each is the number it is.\n *\n * `rootFrequency` 220 Hz (A3) with a `semitones` span of 24 puts the whole\n * range between A3 and A5. Below roughly 200 Hz a laptop or phone speaker\n * reproduces very little, and above about 1 kHz sustained tones start to read\n * as an alarm; two octaves inside that window is the widest span that stays\n * comfortable at both ends. Two octaves is also about where pitch DISCRIMINATION\n * stops improving — stretching to four does not let anyone read the numbers off\n * more precisely, it just makes the top shrill.\n */\nexport const SONIFY_DEFAULTS = {\n noteMs: 220,\n gapMs: 700,\n leadInMs: 1200,\n rootFrequency: 220,\n semitones: 24,\n wave: 'triangle',\n volume: 0.18,\n} as const satisfies Required<Omit<SonifyOptions, 'formatValue'>>\n\n/** Ramp up, in seconds. Short enough to read as an attack, long enough not to click. */\nconst ATTACK_SECONDS = 0.008\n\n/** Ramp down, in seconds. Longer than the attack, as a plucked note is. */\nconst RELEASE_SECONDS = 0.06\n\n/** How much of its slot a note actually sounds for; the rest is the gap between notes. */\nconst NOTE_DUTY = 0.85\n\n/**\n * How far ahead of the clock a note is scheduled.\n *\n * Scheduling at `currentTime` exactly means scheduling in the past by the time\n * the call returns on a busy main thread, and Web Audio silently drops those.\n * Twenty milliseconds is inaudible as latency and is more than the jitter a\n * timer introduces.\n */\nconst LOOKAHEAD_SECONDS = 0.02\n\n/** The fade applied when a note is cut short by a pause or a stop. */\nconst CUT_SECONDS = 0.015\n\n/**\n * A value's pitch, on a semitone scale rather than a linear sweep of hertz.\n *\n * This is the single most important decision in the file. Pitch perception is\n * logarithmic: the ear hears a RATIO, not a difference. 220 Hz to 440 Hz is one\n * octave and so is 440 Hz to 880 Hz, even though the second gap is twice as\n * many hertz. Map values linearly onto hertz and the consequence is immediate —\n * the bottom half of the data spreads across most of the perceived range while\n * the top half compresses into a few near-identical tones, so a rise from 900\n * to 1000 sounds like nothing and a rise from 10 to 110 sounds enormous. The\n * chart lied.\n *\n * Mapping the value range onto a fixed span of SEMITONES fixes it: equal steps\n * in the data become equal musical intervals, which is what a listener actually\n * compares. The map is linear-in-value and exponential-in-frequency, so it is\n * the audio equivalent of a linear axis — not a log axis, which would be a\n * different claim about the data.\n *\n * A flat series (`min === max`) sounds at the middle of the range rather than at\n * the floor: flat data is not low data, and a run of bottom notes reads as one.\n *\n * @example\n * valueToFrequency(50, { min: 0, max: 100 }) // 220 * 2 ** 1 = 440 Hz, one octave up\n */\nexport function valueToFrequency(\n value: number,\n domain: SonifyDomain,\n pitch: Pick<SonifyOptions, 'rootFrequency' | 'semitones'> = {},\n): number {\n const root = pitch.rootFrequency ?? SONIFY_DEFAULTS.rootFrequency\n const semitones = pitch.semitones ?? SONIFY_DEFAULTS.semitones\n const span = domain.max - domain.min\n\n const position = span === 0 ? 0.5 : (value - domain.min) / span\n const clamped = Math.min(1, Math.max(0, position))\n\n return root * 2 ** ((clamped * semitones) / 12)\n}\n\n/**\n * The value range every series is measured against, or `null` when nothing is\n * finite.\n *\n * Shared across all series on purpose. These charts draw their series against\n * ONE value axis, so they have to sound against one pitch axis too — normalising\n * each series to its own extremes would make a flat series that never leaves 2%\n * sound exactly like the one that swings across the whole plot, which is the\n * opposite of what the picture says.\n */\nexport function sonifyDomain(series: SonifySeries[]): SonifyDomain | null {\n let min = Number.POSITIVE_INFINITY\n let max = Number.NEGATIVE_INFINITY\n\n for (const entry of series) {\n for (const value of entry.values) {\n if (value === null || !Number.isFinite(value)) continue\n if (value < min) min = value\n if (value > max) max = value\n }\n }\n\n return min === Number.POSITIVE_INFINITY ? null : { min, max }\n}\n\n/**\n * What is said before a series plays.\n *\n * A run of tones with no frame of reference is unreadable — the listener hears\n * a contour and has no idea whether it spans four units or four million, or how\n * many points went by. Naming the count, the span of the category axis and the\n * two extremes costs one sentence and turns the melody into a measurement. The\n * direction of the mapping is stated too, because \"higher is more\" is a\n * convention, not a fact the sound carries.\n */\nexport function describeSeries(\n series: SonifySeries,\n formatValue: (value: number) => string = defaultTick,\n): string {\n const finite = series.values.filter((value): value is number => value !== null && Number.isFinite(value))\n\n if (finite.length === 0) return `${series.label}, no data.`\n\n const categories = series.categories ?? []\n const first = categories[0]\n const last = categories[categories.length - 1]\n const span = first !== undefined && last !== undefined && first !== last ? `, ${first} to ${last}` : ''\n\n const low = formatValue(Math.min(...finite))\n const high = formatValue(Math.max(...finite))\n const gaps = series.values.length - finite.length\n const missing = gaps > 0 ? ` ${gaps} missing.` : ''\n\n return `${series.label}, ${series.values.length} points${span}. Values from ${low} to ${high}, low pitch to high pitch.${missing}`\n}\n\n/** One thing that happens at one moment: something said, or a note sounded. */\nexport type SonifyStep =\n | { kind: 'announce'; atMs: number; text: string }\n | { kind: 'note'; atMs: number; point: SonifyPoint; frequency: number | null }\n\n/**\n * The whole run, laid out on a timeline before a single sound is made.\n *\n * Built as data rather than driven by a loop with a timer inside it, because\n * this is the part worth testing: the maths that decides when each note lands\n * is checkable without a speaker, and pause/resume becomes \"walk the same array\n * from a different index\" rather than a second, subtly different code path.\n *\n * Several series play **in sequence, each introduced by name**, rather than\n * simultaneously with one panned to each ear. Hard panning is the more\n * impressive demo and the wrong choice: it assumes a stereo output and two\n * usable ears, so a mono speaker, a single hearing aid, or unilateral hearing\n * loss collapses both series into one interleaved melody the listener cannot\n * unpick — and it caps the feature at two series, when four is ordinary. Played\n * in sequence, the worst case is that the run takes longer, which is a cost the\n * listener can see coming and a caller can shorten with `noteMs`.\n */\nexport function sonifyTimeline(series: SonifySeries[], options: SonifyOptions = {}): SonifyStep[] {\n const noteMs = options.noteMs ?? SONIFY_DEFAULTS.noteMs\n const gapMs = options.gapMs ?? SONIFY_DEFAULTS.gapMs\n const leadInMs = options.leadInMs ?? SONIFY_DEFAULTS.leadInMs\n const formatValue = options.formatValue ?? defaultTick\n\n const domain = sonifyDomain(series)\n const steps: SonifyStep[] = []\n let at = 0\n\n series.forEach((entry, entryIndex) => {\n if (entryIndex > 0) at += gapMs\n steps.push({ kind: 'announce', atMs: at, text: describeSeries(entry, formatValue) })\n at += leadInMs\n\n entry.values.forEach((raw, index) => {\n const value = raw !== null && Number.isFinite(raw) ? raw : null\n steps.push({\n kind: 'note',\n atMs: at,\n point: {\n seriesKey: entry.key,\n seriesLabel: entry.label,\n index,\n category: entry.categories?.[index],\n value,\n },\n // A gap keeps its slot and sounds as a rest. Closing it up would move\n // every later point earlier and quietly redraw the shape.\n frequency: value === null || domain === null ? null : valueToFrequency(value, domain, options),\n })\n at += noteMs\n })\n })\n\n return steps\n}\n\n/** How long the whole run takes, in milliseconds. */\nexport function sonifyDuration(series: SonifySeries[], options: SonifyOptions = {}): number {\n const steps = sonifyTimeline(series, options)\n const last = steps[steps.length - 1]\n if (!last) return 0\n return last.kind === 'note' ? last.atMs + (options.noteMs ?? SONIFY_DEFAULTS.noteMs) : last.atMs\n}\n\n/**\n * Whether this environment can make a sound at all.\n *\n * Safe on a server: it reads `window` from inside a function, so importing this\n * module during a server render touches nothing.\n */\nexport function hasAudioSupport(): boolean {\n if (typeof window === 'undefined') return false\n return audioContextConstructor() !== undefined\n}\n\ntype AudioContextConstructor = new () => AudioContext\n\nfunction audioContextConstructor(): AudioContextConstructor | undefined {\n if (typeof window === 'undefined') return undefined\n const scope = window as typeof window & { webkitAudioContext?: AudioContextConstructor }\n return scope.AudioContext ?? scope.webkitAudioContext\n}\n\nexport interface SonifyHandlers {\n /** The point currently sounding, so a chart can mark it. `null` between series. */\n onPoint?: (point: SonifyPoint | null) => void\n /** Text meant for a live region — the range announcement before each series. */\n onAnnounce?: (text: string) => void\n onStateChange?: (state: SonifyState) => void\n /** Fires once the last note has sounded, not when it was scheduled. */\n onEnd?: () => void\n}\n\nexport interface SonifyInput extends SonifyHandlers {\n series: SonifySeries[]\n options?: SonifyOptions\n}\n\nexport interface SonifyController {\n /**\n * Starts, or resumes from where a pause left off.\n *\n * MUST be called from a user gesture. That is a browser rule, not a\n * preference — and it is also the accessibility rule this feature is built\n * on, so there is no way to reach this except from something the reader did.\n */\n play: () => void\n /** Holds position. The current note is faded out rather than cut. */\n pause: () => void\n /** Stops and rewinds to the beginning. */\n stop: () => void\n /** Stops and releases the audio hardware. Call on unmount. */\n destroy: () => void\n readonly state: SonifyState\n /** The whole run, in milliseconds. */\n readonly durationMs: number\n}\n\n/**\n * A run of one chart's series, ready to play.\n *\n * Constructing one allocates nothing but an array — no `AudioContext`, no\n * timer, no node. That matters because a chart builds its controller during\n * render, where creating a context would both break a server render and burn\n * one of the browser's limited context slots on a chart nobody listens to.\n *\n * @example\n * const run = createSonification({\n * series: [{ key: 'desktop', label: 'Desktop', values: [186, 305, 237], categories: ['Jan', 'Feb', 'Mar'] }],\n * onPoint: (point) => setActive(point),\n * })\n * button.addEventListener('click', () => run.play())\n */\nexport function createSonification({\n series,\n options = {},\n onPoint,\n onAnnounce,\n onStateChange,\n onEnd,\n}: SonifyInput): SonifyController {\n const steps = sonifyTimeline(series, options)\n const durationMs = sonifyDuration(series, options)\n const wave = options.wave ?? SONIFY_DEFAULTS.wave\n const volume = options.volume ?? SONIFY_DEFAULTS.volume\n const noteMs = options.noteMs ?? SONIFY_DEFAULTS.noteMs\n\n let state: SonifyState = 'idle'\n let cursor = 0\n let elapsedMs = 0\n let anchorMs = 0\n let timer: ReturnType<typeof setTimeout> | null = null\n let context: AudioContext | null = null\n let master: GainNode | null = null\n const live = new Set<{ oscillator: OscillatorNode; gain: GainNode }>()\n\n function setState(next: SonifyState) {\n if (state === next) return\n state = next\n onStateChange?.(next)\n }\n\n /**\n * The context, built on demand.\n *\n * Returns `null` where audio is unavailable rather than throwing, so a\n * browser without Web Audio degrades to a control that does nothing visible\n * instead of an unhandled error inside a click handler.\n */\n function audio(): { context: AudioContext; master: GainNode } | null {\n if (context && master) return { context, master }\n\n const Constructor = audioContextConstructor()\n if (!Constructor) return null\n\n context = new Constructor()\n master = context.createGain()\n master.gain.value = volume\n master.connect(context.destination)\n return { context, master }\n }\n\n /**\n * One note, with an envelope.\n *\n * The envelope is not polish. An oscillator switched on at full amplitude\n * starts mid-waveform, and that step discontinuity is broadband — it is heard\n * as a click on every single note, which across sixty points is louder and\n * more tiring than the data itself. A few milliseconds of ramp at each end\n * removes it completely.\n */\n function sound(frequency: number, at: number) {\n const engine = audio()\n if (!engine) return\n\n const body = Math.max(ATTACK_SECONDS + RELEASE_SECONDS, (noteMs / 1000) * NOTE_DUTY)\n const oscillator = engine.context.createOscillator()\n const gain = engine.context.createGain()\n\n oscillator.type = wave\n oscillator.frequency.setValueAtTime(frequency, at)\n\n gain.gain.setValueAtTime(0, at)\n gain.gain.linearRampToValueAtTime(1, at + ATTACK_SECONDS)\n gain.gain.setValueAtTime(1, at + body - RELEASE_SECONDS)\n gain.gain.linearRampToValueAtTime(0, at + body)\n\n oscillator.connect(gain)\n gain.connect(engine.master)\n oscillator.start(at)\n oscillator.stop(at + body)\n\n const node = { oscillator, gain }\n live.add(node)\n oscillator.onended = () => {\n live.delete(node)\n gain.disconnect()\n oscillator.disconnect()\n }\n }\n\n /** Fades whatever is sounding, rather than cutting it — a cut clicks too. */\n function silence() {\n if (!context) return\n const now = context.currentTime\n for (const node of live) {\n node.gain.gain.cancelScheduledValues(now)\n node.gain.gain.setValueAtTime(node.gain.gain.value, now)\n node.gain.gain.linearRampToValueAtTime(0, now + CUT_SECONDS)\n node.oscillator.stop(now + CUT_SECONDS)\n }\n }\n\n function clearTimer() {\n if (timer === null) return\n clearTimeout(timer)\n timer = null\n }\n\n function finish() {\n clearTimer()\n cursor = 0\n elapsedMs = 0\n setState('idle')\n onPoint?.(null)\n onEnd?.()\n }\n\n function tick() {\n timer = null\n const step = steps[cursor]\n if (!step) {\n finish()\n return\n }\n\n cursor += 1\n\n if (step.kind === 'announce') {\n onPoint?.(null)\n onAnnounce?.(step.text)\n } else {\n onPoint?.(step.point)\n if (step.frequency !== null && context) {\n sound(step.frequency, context.currentTime + LOOKAHEAD_SECONDS)\n }\n }\n\n schedule()\n }\n\n /** Queues the next step against the run's own clock, so jitter never accumulates. */\n function schedule() {\n const next = steps[cursor]\n const target = next ? next.atMs : durationMs\n const wait = Math.max(0, target - (Date.now() - anchorMs))\n timer = setTimeout(tick, wait)\n }\n\n function play() {\n if (state === 'playing') return\n\n const engine = audio()\n // A context created inside a gesture handler can still arrive suspended —\n // Safari does this — so it is resumed rather than assumed live.\n if (engine && engine.context.state === 'suspended') void engine.context.resume()\n\n anchorMs = Date.now() - elapsedMs\n setState('playing')\n schedule()\n }\n\n function pause() {\n if (state !== 'playing') return\n elapsedMs = Date.now() - anchorMs\n clearTimer()\n silence()\n setState('paused')\n }\n\n function stop() {\n clearTimer()\n silence()\n cursor = 0\n elapsedMs = 0\n setState('idle')\n onPoint?.(null)\n }\n\n function destroy() {\n clearTimer()\n silence()\n cursor = 0\n elapsedMs = 0\n state = 'idle'\n void context?.close()\n context = null\n master = null\n live.clear()\n }\n\n return {\n play,\n pause,\n stop,\n destroy,\n get state() {\n return state\n },\n get durationMs() {\n return durationMs\n },\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,143 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, RefObject, FC } from 'react';
3
+ import { ChartZoom } from './zoom.js';
4
+ import { ChartColumn } from './figure.js';
5
+ import './brush.js';
6
+ import 'recharts';
7
+ import './chart.js';
8
+
9
+ /** Which file a reader can take away. */
10
+ type ChartExportFormat = 'png' | 'csv';
11
+ interface ToolbarProps {
12
+ /**
13
+ * Which files the toolbar offers. `[]` drops the export controls and leaves
14
+ * a zoom-only toolbar.
15
+ */
16
+ exports?: ChartExportFormat[];
17
+ /**
18
+ * Off leaves only the export controls, and the plot stops responding to the
19
+ * wheel, to a drag and to the keyboard. For a chart whose x axis is a handful
20
+ * of categories, where there is nothing to zoom into.
21
+ */
22
+ zoom?: boolean;
23
+ /** Base name for a downloaded file. Defaults to the figure's title. */
24
+ filename?: string;
25
+ /** Which end of the plot the row sits at. */
26
+ align?: 'start' | 'end';
27
+ /** Fires after a file has been handed to the browser. */
28
+ onExport?: (format: ChartExportFormat) => void;
29
+ /**
30
+ * Fires when an export fails — a chart that has not been measured yet, or a
31
+ * canvas tainted by a cross-origin image. Without it the error is logged,
32
+ * because a download that silently does nothing is the worst of the three.
33
+ */
34
+ onExportError?: (error: Error) => void;
35
+ }
36
+ /**
37
+ * Declares the toolbar above a chart: zoom, reset, and taking the figure away
38
+ * as a PNG or a CSV.
39
+ *
40
+ * Renders nothing itself — its PRESENCE among the chart's children turns the
41
+ * toolbar on, and its props configure it, the same shape `<Chart.Brush>` uses.
42
+ * The toolbar is also what switches the plot's own zoom gestures on, so a chart
43
+ * that composes it becomes wheel-, drag- and keyboard-zoomable at the same
44
+ * time.
45
+ *
46
+ * Reach for `<Chart.Brush>` INSTEAD when the reader needs to see the whole
47
+ * series while choosing a slice of it; reach for both when they need to do that
48
+ * and also step the zoom or take the numbers away. The two drive one window, so
49
+ * they cannot disagree.
50
+ *
51
+ * @example
52
+ * <LineChart title="Visitors per day" config={config} data={data} xDataKey="day">
53
+ * <LineChart.Toolbar exports={['png', 'csv']} />
54
+ * <LineChart.XAxis dataKey="day" />
55
+ * <LineChart.Line dataKey="desktop" />
56
+ * </LineChart>
57
+ */
58
+ declare const Toolbar: FC<ToolbarProps>;
59
+ interface ChartToolbarProps extends ToolbarProps {
60
+ /** The element the PNG export reads the plot out of. */
61
+ targetRef: RefObject<HTMLElement | null>;
62
+ /** The figure's title: the toolbar's name, the PNG's caption, the filename. */
63
+ title: string;
64
+ /** The rows a CSV export writes. */
65
+ rows: Record<string, unknown>[];
66
+ /** The columns a CSV export writes, in order. */
67
+ columns: ChartColumn[];
68
+ /** The window these controls drive. */
69
+ zoomState: ChartZoom;
70
+ className?: string;
71
+ }
72
+ /**
73
+ * The row of controls above a plot.
74
+ *
75
+ * A `group` rather than a `toolbar`: the toolbar role promises roving
76
+ * tabindex, and a row of five ordinary buttons that each take a tab stop is
77
+ * both simpler and correct. Every control is a real `<button>` with a real
78
+ * accessible name, repeated as a native `title` for sighted pointer users.
79
+ *
80
+ * There are five controls at most and no overflow menu, which is a size
81
+ * decision as much as a design one: a row that can never exceed five 44px
82
+ * targets does not need one, and the menu component it would take is Radix
83
+ * Menu — around 60 kB that every consumer of every cartesian chart would ship
84
+ * whether or not they compose a toolbar, because the chart reaches it
85
+ * statically. A caller with less width drops controls (`zoom={false}`,
86
+ * `exports={['csv']}`) rather than hiding them behind a second gesture. The
87
+ * hover hints come off the native `title` for the same reason.
88
+ *
89
+ * Sized `md`, which is 44px at the default density. An icon button is exactly
90
+ * the control where the pointer-target floor is most often quietly missed.
91
+ *
92
+ * ## What each export contains
93
+ *
94
+ * The PNG is a picture of the plot, so it shows the CURRENT window — zoom in
95
+ * and the export follows. The CSV is the whole dataset, like the figure's
96
+ * hidden data table, because a spreadsheet silently missing the rows the
97
+ * reader had scrolled past is data loss they cannot see.
98
+ *
99
+ * Composed for you by `<ChartControls>`; reach for it directly only when you
100
+ * are building a chart root of your own.
101
+ *
102
+ * @example
103
+ * <ChartToolbar
104
+ * targetRef={plotRef}
105
+ * title="Visitors per day"
106
+ * rows={data}
107
+ * columns={columns}
108
+ * zoomState={zoom}
109
+ * exports={['png', 'csv']}
110
+ * />
111
+ */
112
+ declare function ChartToolbar({ targetRef, title, rows, columns, zoomState, exports, zoom, filename, align, onExport, onExportError, className, }: ChartToolbarProps): react.JSX.Element | null;
113
+ interface ChartControlsProps {
114
+ /** The composed `<Chart.Toolbar>` props, or null when the chart has none. */
115
+ toolbar: ToolbarProps | null;
116
+ /** The window the toolbar and the plot both drive. */
117
+ zoom: ChartZoom;
118
+ /** The figure's title. */
119
+ title: string;
120
+ /** The whole dataset — what a CSV export writes, zoomed or not. */
121
+ rows: Record<string, unknown>[];
122
+ /** The series columns, in ramp order. */
123
+ columns: ChartColumn[];
124
+ /** The row field naming each row. Written as the CSV's first column. */
125
+ rowKey?: string;
126
+ /** The plot. */
127
+ children: ReactNode;
128
+ }
129
+ /**
130
+ * The toolbar, the plot, and the wiring between them.
131
+ *
132
+ * One component rather than four copies of the same six lines, because every
133
+ * chart that carries a toolbar needs the identical arrangement: the row above,
134
+ * the zoom surface around the plot, and one ref shared between them so the PNG
135
+ * export reads the measured box rather than a guess.
136
+ *
137
+ * With no `<Chart.Toolbar>` composed it renders its children untouched — a
138
+ * chart that did not ask for a toolbar gets no extra element, no extra tab
139
+ * stop, and the layout it had before.
140
+ */
141
+ declare function ChartControls({ toolbar, zoom, title, rows, columns, rowKey, children, }: ChartControlsProps): react.JSX.Element;
142
+
143
+ export { ChartControls, type ChartControlsProps, type ChartExportFormat, ChartToolbar, type ChartToolbarProps, Toolbar, type ToolbarProps };
@@ -0,0 +1,156 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useRef, useState } from "react";
4
+ import { FileDown, ImageDown, RotateCcw, ZoomIn, ZoomOut } from "lucide-react";
5
+ import { cn } from "../../lib/cn.js";
6
+ import { Button } from "../../components/Button/Button.js";
7
+ import { chartToCsv, chartToPng, downloadBlob, exportFilename } from "./export.js";
8
+ import { ChartZoomSurface } from "./zoom.js";
9
+ const Toolbar = () => null;
10
+ function ChartToolbar({
11
+ targetRef,
12
+ title,
13
+ rows,
14
+ columns,
15
+ zoomState,
16
+ exports = ["png", "csv"],
17
+ zoom = true,
18
+ filename,
19
+ align = "end",
20
+ onExport,
21
+ onExportError,
22
+ className
23
+ }) {
24
+ const [busy, setBusy] = useState(null);
25
+ const runExport = async (format) => {
26
+ setBusy(format);
27
+ try {
28
+ if (format === "csv") {
29
+ const csv = chartToCsv(rows, columns);
30
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
31
+ downloadBlob(blob, exportFilename(filename ?? title, "csv"));
32
+ } else {
33
+ const element = targetRef.current;
34
+ if (!element) throw new Error("chart toolbar: the plot is not on the page yet");
35
+ downloadBlob(
36
+ await chartToPng(element, { title }),
37
+ exportFilename(filename ?? title, "png")
38
+ );
39
+ }
40
+ onExport?.(format);
41
+ } catch (cause) {
42
+ const error = cause instanceof Error ? cause : new Error(String(cause));
43
+ if (onExportError) onExportError(error);
44
+ else console.error(error);
45
+ } finally {
46
+ setBusy(null);
47
+ }
48
+ };
49
+ const controls = [];
50
+ if (zoom) {
51
+ controls.push(
52
+ {
53
+ id: "zoom-in",
54
+ label: "Zoom in",
55
+ icon: ZoomIn,
56
+ disabled: !zoomState.canZoomIn,
57
+ run: () => zoomState.zoomIn()
58
+ },
59
+ {
60
+ id: "zoom-out",
61
+ label: "Zoom out",
62
+ icon: ZoomOut,
63
+ disabled: !zoomState.canZoomOut,
64
+ run: () => zoomState.zoomOut()
65
+ },
66
+ {
67
+ id: "reset",
68
+ label: "Reset zoom",
69
+ icon: RotateCcw,
70
+ disabled: !zoomState.isZoomed,
71
+ run: () => zoomState.reset()
72
+ }
73
+ );
74
+ }
75
+ if (exports.includes("png")) {
76
+ controls.push({
77
+ id: "png",
78
+ label: "Download PNG",
79
+ icon: ImageDown,
80
+ busy: busy === "png",
81
+ disabled: busy !== null,
82
+ run: () => void runExport("png")
83
+ });
84
+ }
85
+ if (exports.includes("csv")) {
86
+ controls.push({
87
+ id: "csv",
88
+ label: "Download CSV",
89
+ icon: FileDown,
90
+ busy: busy === "csv",
91
+ disabled: busy !== null,
92
+ run: () => void runExport("csv")
93
+ });
94
+ }
95
+ if (controls.length === 0) return null;
96
+ return /* @__PURE__ */ jsx(
97
+ "div",
98
+ {
99
+ role: "group",
100
+ "aria-label": `${title} \u2014 chart controls`,
101
+ className: cn(
102
+ "flex items-center gap-1",
103
+ align === "end" ? "justify-end" : "justify-start",
104
+ className
105
+ ),
106
+ children: controls.map((control) => /* @__PURE__ */ jsx(
107
+ Button,
108
+ {
109
+ iconOnly: true,
110
+ variant: "ghost",
111
+ size: "md",
112
+ "aria-label": control.label,
113
+ title: control.label,
114
+ disabled: control.disabled,
115
+ loading: control.busy,
116
+ onClick: control.run,
117
+ children: control.busy ? null : /* @__PURE__ */ jsx(control.icon, { size: 16, strokeWidth: 1.5, "aria-hidden": true })
118
+ },
119
+ control.id
120
+ ))
121
+ }
122
+ );
123
+ }
124
+ function ChartControls({
125
+ toolbar,
126
+ zoom,
127
+ title,
128
+ rows,
129
+ columns,
130
+ rowKey,
131
+ children
132
+ }) {
133
+ const plotRef = useRef(null);
134
+ if (!toolbar) return /* @__PURE__ */ jsx(Fragment, { children });
135
+ const csvColumns = rowKey ? [{ key: rowKey, label: rowKey }, ...columns] : columns;
136
+ return /* @__PURE__ */ jsxs("div", { className: "flex min-h-0 w-full flex-1 flex-col", children: [
137
+ /* @__PURE__ */ jsx(
138
+ ChartToolbar,
139
+ {
140
+ ...toolbar,
141
+ targetRef: plotRef,
142
+ title,
143
+ rows,
144
+ columns: csvColumns,
145
+ zoomState: zoom
146
+ }
147
+ ),
148
+ /* @__PURE__ */ jsx(ChartZoomSurface, { ref: plotRef, zoom, label: title, enabled: toolbar.zoom !== false, children })
149
+ ] });
150
+ }
151
+ export {
152
+ ChartControls,
153
+ ChartToolbar,
154
+ Toolbar
155
+ };
156
+ //# sourceMappingURL=toolbar.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/charts/lib/toolbar.tsx"],"sourcesContent":["'use client'\n\nimport { useRef, useState, type FC, type ReactNode, type RefObject } from 'react'\nimport { FileDown, ImageDown, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react'\nimport type { LucideIcon } from 'lucide-react'\nimport { cn } from '../../lib/cn'\nimport { Button } from '../../components/Button/Button'\nimport { chartToCsv, chartToPng, downloadBlob, exportFilename } from './export'\nimport { ChartZoomSurface, type ChartZoom } from './zoom'\nimport type { ChartColumn } from './figure'\n\n/** Which file a reader can take away. */\nexport type ChartExportFormat = 'png' | 'csv'\n\nexport interface ToolbarProps {\n /**\n * Which files the toolbar offers. `[]` drops the export controls and leaves\n * a zoom-only toolbar.\n */\n exports?: ChartExportFormat[]\n /**\n * Off leaves only the export controls, and the plot stops responding to the\n * wheel, to a drag and to the keyboard. For a chart whose x axis is a handful\n * of categories, where there is nothing to zoom into.\n */\n zoom?: boolean\n /** Base name for a downloaded file. Defaults to the figure's title. */\n filename?: string\n /** Which end of the plot the row sits at. */\n align?: 'start' | 'end'\n /** Fires after a file has been handed to the browser. */\n onExport?: (format: ChartExportFormat) => void\n /**\n * Fires when an export fails — a chart that has not been measured yet, or a\n * canvas tainted by a cross-origin image. Without it the error is logged,\n * because a download that silently does nothing is the worst of the three.\n */\n onExportError?: (error: Error) => void\n}\n\n/**\n * Declares the toolbar above a chart: zoom, reset, and taking the figure away\n * as a PNG or a CSV.\n *\n * Renders nothing itself — its PRESENCE among the chart's children turns the\n * toolbar on, and its props configure it, the same shape `<Chart.Brush>` uses.\n * The toolbar is also what switches the plot's own zoom gestures on, so a chart\n * that composes it becomes wheel-, drag- and keyboard-zoomable at the same\n * time.\n *\n * Reach for `<Chart.Brush>` INSTEAD when the reader needs to see the whole\n * series while choosing a slice of it; reach for both when they need to do that\n * and also step the zoom or take the numbers away. The two drive one window, so\n * they cannot disagree.\n *\n * @example\n * <LineChart title=\"Visitors per day\" config={config} data={data} xDataKey=\"day\">\n * <LineChart.Toolbar exports={['png', 'csv']} />\n * <LineChart.XAxis dataKey=\"day\" />\n * <LineChart.Line dataKey=\"desktop\" />\n * </LineChart>\n */\nexport const Toolbar: FC<ToolbarProps> = () => null\n\n/** One button on the row, or one row in the overflow menu. */\ninterface ToolbarControl {\n id: string\n /** The accessible name, the tooltip, and the menu row's text. One string. */\n label: string\n icon: LucideIcon\n disabled?: boolean\n busy?: boolean\n run: () => void\n}\n\nexport interface ChartToolbarProps extends ToolbarProps {\n /** The element the PNG export reads the plot out of. */\n targetRef: RefObject<HTMLElement | null>\n /** The figure's title: the toolbar's name, the PNG's caption, the filename. */\n title: string\n /** The rows a CSV export writes. */\n rows: Record<string, unknown>[]\n /** The columns a CSV export writes, in order. */\n columns: ChartColumn[]\n /** The window these controls drive. */\n zoomState: ChartZoom\n className?: string\n}\n\n/**\n * The row of controls above a plot.\n *\n * A `group` rather than a `toolbar`: the toolbar role promises roving\n * tabindex, and a row of five ordinary buttons that each take a tab stop is\n * both simpler and correct. Every control is a real `<button>` with a real\n * accessible name, repeated as a native `title` for sighted pointer users.\n *\n * There are five controls at most and no overflow menu, which is a size\n * decision as much as a design one: a row that can never exceed five 44px\n * targets does not need one, and the menu component it would take is Radix\n * Menu — around 60 kB that every consumer of every cartesian chart would ship\n * whether or not they compose a toolbar, because the chart reaches it\n * statically. A caller with less width drops controls (`zoom={false}`,\n * `exports={['csv']}`) rather than hiding them behind a second gesture. The\n * hover hints come off the native `title` for the same reason.\n *\n * Sized `md`, which is 44px at the default density. An icon button is exactly\n * the control where the pointer-target floor is most often quietly missed.\n *\n * ## What each export contains\n *\n * The PNG is a picture of the plot, so it shows the CURRENT window — zoom in\n * and the export follows. The CSV is the whole dataset, like the figure's\n * hidden data table, because a spreadsheet silently missing the rows the\n * reader had scrolled past is data loss they cannot see.\n *\n * Composed for you by `<ChartControls>`; reach for it directly only when you\n * are building a chart root of your own.\n *\n * @example\n * <ChartToolbar\n * targetRef={plotRef}\n * title=\"Visitors per day\"\n * rows={data}\n * columns={columns}\n * zoomState={zoom}\n * exports={['png', 'csv']}\n * />\n */\nexport function ChartToolbar({\n targetRef,\n title,\n rows,\n columns,\n zoomState,\n exports = ['png', 'csv'],\n zoom = true,\n filename,\n align = 'end',\n onExport,\n onExportError,\n className,\n}: ChartToolbarProps) {\n const [busy, setBusy] = useState<ChartExportFormat | null>(null)\n\n const runExport = async (format: ChartExportFormat) => {\n setBusy(format)\n try {\n if (format === 'csv') {\n const csv = chartToCsv(rows, columns)\n const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })\n downloadBlob(blob, exportFilename(filename ?? title, 'csv'))\n } else {\n const element = targetRef.current\n if (!element) throw new Error('chart toolbar: the plot is not on the page yet')\n downloadBlob(\n await chartToPng(element, { title }),\n exportFilename(filename ?? title, 'png'),\n )\n }\n onExport?.(format)\n } catch (cause) {\n const error = cause instanceof Error ? cause : new Error(String(cause))\n // Never swallowed: the reader clicked a download and something has to\n // say it did not arrive.\n if (onExportError) onExportError(error)\n else console.error(error)\n } finally {\n setBusy(null)\n }\n }\n\n const controls: ToolbarControl[] = []\n\n if (zoom) {\n controls.push(\n {\n id: 'zoom-in',\n label: 'Zoom in',\n icon: ZoomIn,\n disabled: !zoomState.canZoomIn,\n run: () => zoomState.zoomIn(),\n },\n {\n id: 'zoom-out',\n label: 'Zoom out',\n icon: ZoomOut,\n disabled: !zoomState.canZoomOut,\n run: () => zoomState.zoomOut(),\n },\n {\n id: 'reset',\n label: 'Reset zoom',\n icon: RotateCcw,\n disabled: !zoomState.isZoomed,\n run: () => zoomState.reset(),\n },\n )\n }\n\n if (exports.includes('png')) {\n controls.push({\n id: 'png',\n label: 'Download PNG',\n icon: ImageDown,\n busy: busy === 'png',\n disabled: busy !== null,\n run: () => void runExport('png'),\n })\n }\n\n if (exports.includes('csv')) {\n controls.push({\n id: 'csv',\n label: 'Download CSV',\n icon: FileDown,\n busy: busy === 'csv',\n disabled: busy !== null,\n run: () => void runExport('csv'),\n })\n }\n\n if (controls.length === 0) return null\n\n return (\n <div\n role=\"group\"\n aria-label={`${title} — chart controls`}\n className={cn(\n 'flex items-center gap-1',\n align === 'end' ? 'justify-end' : 'justify-start',\n className,\n )}\n >\n {controls.map((control) => (\n <Button\n key={control.id}\n iconOnly\n variant=\"ghost\"\n size=\"md\"\n aria-label={control.label}\n // The same string again as a native hint. `aria-label` is what a\n // screen reader announces and `title` is what a pointer user sees;\n // both read the one label, so they cannot drift apart.\n title={control.label}\n disabled={control.disabled}\n loading={control.busy}\n onClick={control.run}\n >\n {/* Dropped while the spinner is up, so a 44px square never holds\n two glyphs at once. */}\n {control.busy ? null : <control.icon size={16} strokeWidth={1.5} aria-hidden />}\n </Button>\n ))}\n </div>\n )\n}\n\nexport interface ChartControlsProps {\n /** The composed `<Chart.Toolbar>` props, or null when the chart has none. */\n toolbar: ToolbarProps | null\n /** The window the toolbar and the plot both drive. */\n zoom: ChartZoom\n /** The figure's title. */\n title: string\n /** The whole dataset — what a CSV export writes, zoomed or not. */\n rows: Record<string, unknown>[]\n /** The series columns, in ramp order. */\n columns: ChartColumn[]\n /** The row field naming each row. Written as the CSV's first column. */\n rowKey?: string\n /** The plot. */\n children: ReactNode\n}\n\n/**\n * The toolbar, the plot, and the wiring between them.\n *\n * One component rather than four copies of the same six lines, because every\n * chart that carries a toolbar needs the identical arrangement: the row above,\n * the zoom surface around the plot, and one ref shared between them so the PNG\n * export reads the measured box rather than a guess.\n *\n * With no `<Chart.Toolbar>` composed it renders its children untouched — a\n * chart that did not ask for a toolbar gets no extra element, no extra tab\n * stop, and the layout it had before.\n */\nexport function ChartControls({\n toolbar,\n zoom,\n title,\n rows,\n columns,\n rowKey,\n children,\n}: ChartControlsProps) {\n const plotRef = useRef<HTMLDivElement>(null)\n\n if (!toolbar) return <>{children}</>\n\n // The category column first, matching the hidden data table: a CSV whose\n // rows are unlabelled numbers is a CSV nobody can join anything to.\n const csvColumns = rowKey ? [{ key: rowKey, label: rowKey }, ...columns] : columns\n\n return (\n <div className=\"flex min-h-0 w-full flex-1 flex-col\">\n <ChartToolbar\n {...toolbar}\n targetRef={plotRef}\n title={title}\n rows={rows}\n columns={csvColumns}\n zoomState={zoom}\n />\n <ChartZoomSurface ref={plotRef} zoom={zoom} label={title} enabled={toolbar.zoom !== false}>\n {children}\n </ChartZoomSurface>\n </div>\n )\n}\n"],"mappings":"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","names":[]}