railsui_charts 0.1.2 → 0.2.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 90aab15c8c1ef133527f757c7e24344da84a62a190825a13decee8979cf79892
4
- data.tar.gz: db6626712782f3a93d92ea8c149d9fbe27cece328b1cfca4b611269dd06a8c0b
3
+ metadata.gz: '049e45e39a745fa833e9ba7835a09ecf960cb837b5d86c07c48d311201cddd6e'
4
+ data.tar.gz: 5b649ab130a83f43fa87490699fc5514eb00ee9eb92d5bdf6d059e31ac61f73e
5
5
  SHA512:
6
- metadata.gz: 94345b1a73e7cdd1e2760ee58462f6af60396019295cb0a05a5f300120c2d5ed192d64770cb38254abfa959e94ca5ab51ed9353107163e5f2eb75a22e42611a9
7
- data.tar.gz: e89fad7105008595f9de2070044287e1c193a09ab83dd327492a794106259d24783fc30ed580e19c07a40c8a7ea06ed2fbb7ac5780d620af0eafe7ce643da9d8
6
+ metadata.gz: 9d86406916a9fc276372ae9f200f0a0b7fd4d27327caa0e56264d1e79f7eb315c1dde4293c2c275da3f63d02ba47df38524c8d7cb3119ce7204cb36cf5754580
7
+ data.tar.gz: 2aacd7f09219f90924d07f5412fc4a25e62fcf312bd4903817138fea5f326d0d1ba48ec3169c416ac5e94141d43dd801c47dde340acc2a7b432c1761abd0aee8
data/CHANGELOG.md CHANGED
@@ -6,6 +6,62 @@ public API may change between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Fixed
10
+
11
+ - Sparkline charts now use the Rails UI tooltip renderer, so hovered points show
12
+ their label and formatted value instead of an empty tooltip.
13
+ - Range-bar and labelled-point tooltips now read the hovered point from the
14
+ chart config, so timelines and treemaps show the right title, value, and
15
+ distributed color.
16
+ - Range-bar points keep their `name` and `meta` payloads for tooltip consumers.
17
+ - Chart loading skeletons now follow the same treatment as metric cards: they
18
+ reserve the footprint, show lightweight skeleton bars, and leave the plot area
19
+ empty until real data arrives. They align left by default and accept
20
+ `align: :center` or `align: :right` when a layout needs it.
21
+
22
+ ## [0.2.0]
23
+
24
+ The JavaScript now comes from the gem instead of being copied into the
25
+ application. This is a breaking change to how it is installed; the helpers, the
26
+ chart API and the CSS are untouched.
27
+
28
+ ### Added
29
+
30
+ - An npm package, `@getrailsui/charts`, for applications that bundle their
31
+ JavaScript. It carries the Stimulus controllers and a
32
+ `registerRailsuiCharts(application)` helper that registers each one under the
33
+ identifier the Ruby helpers emit.
34
+ - Importmap support. The engine adds its own pins to the host application's
35
+ importmap and serves a prebuilt, self-contained build, so an importmap app
36
+ needs no package and no copied files. Both worlds use the same import line.
37
+ - `@hotwired/stimulus` and `apexcharts` are declared as peer dependencies rather
38
+ than bundled, so an application keeps one copy of each.
39
+
40
+ ### Changed
41
+
42
+ - **`rails g railsui_charts:install` no longer copies the controllers.** It adds
43
+ the stylesheet import and prints the right next step for a bundled or an
44
+ importmap app.
45
+ - The install generator writes through its own file actions rather than
46
+ `Rails.root`, so it respects `destination_root` — which is also what made the
47
+ generators testable.
48
+
49
+ ### Removed
50
+
51
+ - The duplicate controllers under `lib/generators/.../templates`. They had to be
52
+ kept in sync with the engine's own copies by hand, and had already drifted.
53
+
54
+ ### Upgrading
55
+
56
+ Delete the copied controllers from `app/javascript/controllers` and follow the
57
+ README. Left in place they register the same identifiers a second time, and
58
+ Stimulus keeps the last one without complaint.
59
+
60
+ This is why it changed: a copy is frozen at the version that installed it.
61
+ `bundle update` moved the Ruby and left the JavaScript alone, so 0.1.2's fix for
62
+ empty tooltips on pie, donut and polar area charts reached nobody who did not
63
+ also know to re-copy a file nothing told them about.
64
+
9
65
  ## [0.1.2]
10
66
 
11
67
  ### Fixed
@@ -67,7 +123,8 @@ First release.
67
123
  reachable only by hovering a mark
68
124
  - Animation stops when the reader has asked for reduced motion
69
125
 
70
- [Unreleased]: https://github.com/getrailsui/railsui_charts/compare/v0.1.2...HEAD
126
+ [Unreleased]: https://github.com/getrailsui/railsui_charts/compare/v0.2.0...HEAD
127
+ [0.2.0]: https://github.com/getrailsui/railsui_charts/compare/v0.1.2...v0.2.0
71
128
  [0.1.2]: https://github.com/getrailsui/railsui_charts/compare/v0.1.1...v0.1.2
72
129
  [0.1.1]: https://github.com/getrailsui/railsui_charts/compare/v0.1.0...v0.1.1
73
130
  [0.1.0]: https://github.com/getrailsui/railsui_charts/releases/tag/v0.1.0
data/README.md CHANGED
@@ -35,28 +35,63 @@ Add to your Gemfile:
35
35
  gem "railsui_charts"
36
36
  ```
37
37
 
38
- Then run:
38
+ Then:
39
39
 
40
40
  ```bash
41
41
  bundle install
42
42
  rails g railsui_charts:install
43
43
  ```
44
44
 
45
- The generator adds the CSS import and copies the Stimulus controller. You still need ApexCharts in your JavaScript:
45
+ The generator adds the stylesheet import. The JavaScript is served from the gem
46
+ rather than copied into your app, so `bundle update` moves all of it.
46
47
 
47
- **Build mode:**
48
+ ### Bundled apps (esbuild, bun, rollup, webpack)
48
49
 
49
50
  ```bash
50
- yarn add apexcharts
51
+ yarn add @getrailsui/charts apexcharts
51
52
  ```
52
53
 
53
- **No-build (importmap):**
54
+ ```js
55
+ // app/javascript/controllers/index.js
56
+ import { registerRailsuiCharts } from "@getrailsui/charts"
57
+
58
+ registerRailsuiCharts(application)
59
+ ```
60
+
61
+ ### Importmap
62
+
63
+ Nothing to add for the controllers — the engine pins them. ApexCharts is a peer
64
+ dependency, so pin that:
54
65
 
55
66
  ```ruby
56
67
  # config/importmap.rb
57
68
  pin "apexcharts", to: "https://esm.sh/apexcharts@3.45.2"
58
69
  ```
59
70
 
71
+ ```js
72
+ // app/javascript/controllers/index.js
73
+ import { registerRailsuiCharts } from "@getrailsui/charts"
74
+
75
+ registerRailsuiCharts(application)
76
+ ```
77
+
78
+ ### Upgrading from 0.1.x
79
+
80
+ 0.1.x copied the controllers into `app/javascript/controllers`. Those copies are
81
+ frozen at whatever version installed them — `bundle update` moved the Ruby and
82
+ left them alone, which is the reason this changed. Delete them and follow one of
83
+ the paths above:
84
+
85
+ ```bash
86
+ rm app/javascript/controllers/railsui_chart_controller.js \
87
+ app/javascript/controllers/railsui_chart_filters_controller.js \
88
+ app/javascript/controllers/railsui_metric_dialog_controller.js
89
+ ```
90
+
91
+ Leaving them in place registers the same identifiers twice. Stimulus keeps the
92
+ last registration and says nothing about it, so the symptom is a chart behaving
93
+ like an older version of itself.
94
+
60
95
  ## Usage
61
96
 
62
97
  All charts use the same `railsui_chart` helper. Change the `type:` to switch chart kinds.
@@ -475,12 +510,14 @@ A series of zeroes is **not** empty. A quiet day still has something to say, and
475
510
 
476
511
  ```erb
477
512
  <%= railsui_metric_card_skeleton chart_height: 180 %>
478
- <%= railsui_chart_skeleton height: 240, type: :donut %>
513
+ <%= railsui_chart_skeleton height: 240 %>
479
514
  ```
480
515
 
481
516
  Render one server-side and let a Turbo Stream swap in the real thing.
482
517
 
483
- The card skeleton stands in for the text that is coming and leaves the plot area empty a slab where the chart goes claims more about the shape of the data than a loading state can know. A standalone chart skeleton keeps faint gridlines, since it has nothing else to say it is a chart, and takes the shape of its `type:`.
518
+ The loading helpers stand in for the text and chrome that are coming and leave the plot area empty. A slab where the chart goes claims more about the shape of the data than a loading state can know.
519
+
520
+ Chart skeletons align left by default. Pass `align: :center` or `align: :right` when a specific layout needs it.
484
521
 
485
522
  On **refetch**, don't reach for the skeleton. Any element inside a container marked `aria-busy="true"` — which is what Turbo does to a frame while it loads — holds its previous render at reduced opacity instead. The numbers stay on screen and the layout stays still; a skeleton would throw the chart away and flash.
486
523
 
@@ -515,7 +552,7 @@ The Stimulus controller initializes charts on `connect` and destroys them on `di
515
552
 
516
553
  ## Full access
517
554
 
518
- A Rails UI membership adds `railsui_charts_pro`, which builds on this gem
555
+ A Rails UI subscription adds `railsui_charts_pro`, which builds on this gem
519
556
  rather than replacing it:
520
557
 
521
558
  - **Time series** — `TimeSeries` fills the gaps a `GROUP BY` leaves behind, buckets to any interval, and sums rows that collapse together
@@ -0,0 +1,486 @@
1
+ // app/javascript/controllers/railsui_chart_controller.js
2
+ import { Controller } from "@hotwired/stimulus";
3
+ import ApexCharts from "apexcharts";
4
+ var railsui_chart_controller_default = class extends Controller {
5
+ static values = { options: Object };
6
+ connect() {
7
+ this.bindThemeListeners();
8
+ this.refresh = this.refresh.bind(this);
9
+ this.element.addEventListener("railsui-chart:refresh", this.refresh);
10
+ this.watchViewport();
11
+ }
12
+ disconnect() {
13
+ this.unwatchViewport();
14
+ this.element.removeEventListener("railsui-chart:refresh", this.refresh);
15
+ this.destroy();
16
+ this.unbindThemeListeners();
17
+ }
18
+ // Building every chart at once is work the reader has not asked for, and a
19
+ // page carrying a dozen of them spends the first seconds laying out charts
20
+ // nobody is looking at. Each one waits until it is nearly on screen; the
21
+ // server reserves its height so nothing shifts when it arrives.
22
+ watchViewport() {
23
+ if (!("IntersectionObserver" in window)) {
24
+ if (this.measurable) this.render();
25
+ return;
26
+ }
27
+ this.viewportObserver = new IntersectionObserver(
28
+ (entries) => {
29
+ if (!entries.some((entry) => entry.isIntersecting)) return;
30
+ this.unwatchViewport();
31
+ if (this.measurable) this.render();
32
+ },
33
+ // Start early enough that it is drawn by the time it is scrolled to.
34
+ { rootMargin: "300px 0px" }
35
+ );
36
+ this.viewportObserver.observe(this.element);
37
+ }
38
+ unwatchViewport() {
39
+ this.viewportObserver?.disconnect();
40
+ this.viewportObserver = null;
41
+ }
42
+ // An explicit reveal outranks the viewport check — a dialog opening means
43
+ // draw it now.
44
+ refresh() {
45
+ this.unwatchViewport();
46
+ if (this.measurable) this.render();
47
+ }
48
+ rerender() {
49
+ if (this.chart) this.render();
50
+ }
51
+ // Anything inside a `display: none` subtree — a closed <dialog>, a hidden
52
+ // tab panel — reports no client rects.
53
+ get measurable() {
54
+ return this.element.getClientRects().length > 0;
55
+ }
56
+ render() {
57
+ this.destroy();
58
+ this.chart = new ApexCharts(this.element, this.resolvedOptions());
59
+ this.chart.render();
60
+ }
61
+ destroy() {
62
+ if (this.chart) {
63
+ this.chart.destroy();
64
+ this.chart = null;
65
+ }
66
+ }
67
+ resolvedOptions() {
68
+ const options = this.resolveCssVariables({
69
+ ...this.optionsValue,
70
+ chart: {
71
+ ...this.optionsValue.chart || {},
72
+ animations: {
73
+ ...this.optionsValue.chart?.animations || {},
74
+ enabled: this.animationsEnabled
75
+ }
76
+ },
77
+ theme: {
78
+ ...this.optionsValue.theme,
79
+ // Last word: the server ships a static `light` default it cannot know
80
+ // better than, so spreading it after would pin every chart to light.
81
+ mode: this.darkMode ? "dark" : "light"
82
+ }
83
+ });
84
+ return this.applyTooltip(this.applyEdgeLabels(this.applyFormatters(options)));
85
+ }
86
+ // Apex's stock tooltip is a dark slab whatever the page is doing. This one is
87
+ // built from the same CSS variables as everything else, so it follows the
88
+ // theme, and it leads with what changed rather than with the date.
89
+ applyTooltip(options) {
90
+ if (options.tooltip_style === false) return options;
91
+ if (options.tooltip?.custom) return options;
92
+ const format = this.formatterFor(options.format || "number", options.currency);
93
+ const rowFormats = (options.series_formats || []).map((name) => this.formatterFor(name, options.currency));
94
+ const comparedDates = options.compare_categories || [];
95
+ const upIsGood = options.trend_up_is_good !== false;
96
+ const showDelta = options.tooltip_delta !== false;
97
+ const headingMode = options.tooltip_heading;
98
+ return {
99
+ ...options,
100
+ tooltip: {
101
+ ...options.tooltip || {},
102
+ custom: ({ series, seriesIndex, dataPointIndex, w }) => {
103
+ const labels = w.globals.categoryLabels?.length ? w.globals.categoryLabels : w.globals.labels;
104
+ if (options.chart?.type === "rangeBar") {
105
+ return this.rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format);
106
+ }
107
+ const configuredPoint = this.configuredPoint(options, w, seriesIndex, dataPointIndex);
108
+ if (this.labelledPoint(configuredPoint)) {
109
+ return this.labelledPointTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format);
110
+ }
111
+ if (!Array.isArray(series[0])) {
112
+ const slice = seriesIndex ?? dataPointIndex;
113
+ const row = { label: "", value: series[slice], color: w.globals.colors[slice], format: rowFormats[slice] || null };
114
+ return this.tooltipMarkup(labels?.[slice], [row], null, format);
115
+ }
116
+ const point = (index) => series[index]?.[dataPointIndex];
117
+ const comparing = series.length === 2 && comparedDates.length > 0;
118
+ const rows = series.map((_, index) => ({
119
+ // In a comparison the two rows are the same metric at different
120
+ // dates, so the date identifies them. Otherwise the name does.
121
+ label: comparing ? index === 0 ? labels?.[dataPointIndex] : comparedDates[dataPointIndex] : w.globals.seriesNames[index],
122
+ value: point(index),
123
+ color: w.globals.colors[index],
124
+ format: rowFormats[index] || null
125
+ }));
126
+ const leadsWithSeries = headingMode ? headingMode === "series" : comparing;
127
+ const heading = leadsWithSeries ? w.globals.seriesNames[0] : labels?.[dataPointIndex];
128
+ const delta = showDelta && comparing ? this.tooltipDelta(point(0), point(1), upIsGood) : null;
129
+ return this.tooltipMarkup(heading, rows, delta, format);
130
+ }
131
+ }
132
+ };
133
+ }
134
+ configuredPoint(options, w, seriesIndex, dataPointIndex) {
135
+ return (w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {}).data?.[dataPointIndex];
136
+ }
137
+ labelledPoint(point) {
138
+ return point !== null && typeof point === "object" && !Array.isArray(point) && "x" in point && "y" in point;
139
+ }
140
+ labelledPointTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
141
+ const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {};
142
+ const point = this.configuredPoint(options, w, seriesIndex, dataPointIndex) || {};
143
+ const title = point.name || point.label || point.x || configuredSeries.name;
144
+ const row = {
145
+ label: configuredSeries.name || "Value",
146
+ value: point.y ?? series?.[seriesIndex]?.[dataPointIndex],
147
+ color: this.pointColor(options, w, seriesIndex, dataPointIndex),
148
+ format: null
149
+ };
150
+ return this.tooltipMarkup(title, [row], null, format);
151
+ }
152
+ rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
153
+ const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {};
154
+ const point = configuredSeries.data?.[dataPointIndex] || {};
155
+ const fallbackValue = series?.[seriesIndex]?.[dataPointIndex];
156
+ const value = Array.isArray(point.y) ? point.y : fallbackValue;
157
+ const title = point.name || point.label || point.meta?.label || point.x || w.globals.categoryLabels?.[dataPointIndex] || w.globals.labels?.[dataPointIndex] || configuredSeries.name;
158
+ const rowLabel = point.name || point.label || point.meta?.label ? point.x || configuredSeries.name || "Range" : "Range";
159
+ const row = {
160
+ label: rowLabel,
161
+ value: this.formatRangeValue(value, options, format),
162
+ color: this.pointColor(options, w, seriesIndex, dataPointIndex),
163
+ format: null
164
+ };
165
+ return this.tooltipMarkup(title, [row], null, null);
166
+ }
167
+ pointColor(options, w, seriesIndex, dataPointIndex) {
168
+ if (options.plotOptions?.bar?.distributed && Array.isArray(options.colors)) {
169
+ return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex];
170
+ }
171
+ if (options.plotOptions?.treemap?.distributed && Array.isArray(options.colors)) {
172
+ return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex];
173
+ }
174
+ return w.globals.colors?.[seriesIndex] || options.colors?.[seriesIndex] || options.colors?.[0];
175
+ }
176
+ formatRangeValue(value, options, format) {
177
+ const values = Array.isArray(value) ? value : [value];
178
+ const present = values.filter((entry) => entry !== null && entry !== void 0);
179
+ if (present.length === 0) return null;
180
+ if (present.length === 2 && present.every((entry) => this.timestampLike(entry))) {
181
+ const [start, finish] = present;
182
+ if (this.sameTimestampDay(start, finish, options)) {
183
+ const startTime = this.formatTimestampTime(start, options);
184
+ const finishTime = this.formatTimestampTime(finish, options);
185
+ return startTime === finishTime ? `${this.formatTimestampDate(start, options)}, ${startTime}` : `${this.formatTimestampDate(start, options)}, ${startTime} - ${finishTime}`;
186
+ }
187
+ }
188
+ const formatted = present.map((entry) => this.formatRangeEndpoint(entry, options, format));
189
+ if (formatted.length === 1 || formatted[0] === formatted[1]) return formatted[0];
190
+ return `${formatted[0]} - ${formatted[formatted.length - 1]}`;
191
+ }
192
+ formatRangeEndpoint(value, options, format) {
193
+ if (this.timestampLike(value)) return `${this.formatTimestampDate(value, options)}, ${this.formatTimestampTime(value, options)}`;
194
+ if (format) return format(value);
195
+ return value;
196
+ }
197
+ timestampLike(value) {
198
+ return typeof value === "number" && Number.isFinite(value) && Math.abs(value) >= 1e11;
199
+ }
200
+ sameTimestampDay(left, right, options) {
201
+ const leftDate = new Date(left);
202
+ const rightDate = new Date(right);
203
+ if (this.datetimeUTC(options)) {
204
+ return leftDate.toISOString().slice(0, 10) === rightDate.toISOString().slice(0, 10);
205
+ }
206
+ return leftDate.getFullYear() === rightDate.getFullYear() && leftDate.getMonth() === rightDate.getMonth() && leftDate.getDate() === rightDate.getDate();
207
+ }
208
+ formatTimestampDate(value, options) {
209
+ return new Intl.DateTimeFormat("en-US", {
210
+ ...this.datetimeUTC(options) ? { timeZone: "UTC" } : {},
211
+ month: "short",
212
+ day: "numeric"
213
+ }).format(new Date(value));
214
+ }
215
+ formatTimestampTime(value, options) {
216
+ return new Intl.DateTimeFormat("en-US", {
217
+ ...this.datetimeUTC(options) ? { timeZone: "UTC" } : {},
218
+ hour: "numeric",
219
+ minute: "2-digit"
220
+ }).format(new Date(value));
221
+ }
222
+ datetimeUTC(options) {
223
+ return options.xaxis?.labels?.datetimeUTC !== false;
224
+ }
225
+ tooltipDelta(current, previous, upIsGood) {
226
+ if (![current, previous].every((n) => typeof n === "number") || previous === 0) return null;
227
+ const change = (current - previous) / Math.abs(previous) * 100;
228
+ if (!isFinite(change)) return null;
229
+ const rounded = Math.round(change * 100) / 100;
230
+ return {
231
+ text: `${rounded > 0 ? "+" : ""}${rounded}%`,
232
+ tone: rounded === 0 ? "neutral" : rounded > 0 === upIsGood ? "positive" : "negative"
233
+ };
234
+ }
235
+ tooltipMarkup(heading, rows, delta, format) {
236
+ const cells = rows.filter((row) => row.value !== null && row.value !== void 0).map(
237
+ (row) => `
238
+ <tr>
239
+ <th scope="row">
240
+ <span class="railsui-chart-tooltip__key" style="background:${this.escape(row.color)}"></span>
241
+ ${this.escape(row.label)}
242
+ </th>
243
+ <td>${this.escape(this.formatRow(row, format))}</td>
244
+ </tr>`
245
+ ).join("");
246
+ const badge = delta ? `<span class="railsui-chart-tooltip__delta railsui-chart-tooltip__delta--${delta.tone}">${this.escape(delta.text)}</span>` : "";
247
+ return `
248
+ <div class="railsui-chart-tooltip">
249
+ <div class="railsui-chart-tooltip__head">
250
+ <span class="railsui-chart-tooltip__title">${this.escape(heading)}</span>${badge}
251
+ </div>
252
+ <table class="railsui-chart-tooltip__rows">${cells}</table>
253
+ </div>`;
254
+ }
255
+ // A row's own formatter when it has one, the chart's otherwise.
256
+ formatRow(row, fallback) {
257
+ const format = row.format || fallback;
258
+ return format ? format(row.value) : row.value;
259
+ }
260
+ // Series names and category labels come from application data.
261
+ escape(value) {
262
+ return String(value ?? "").replace(/[&<>"']/g, (char) => {
263
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char];
264
+ });
265
+ }
266
+ // Stripe-style cards label only the first and last tick, so the axis reads as
267
+ // a range rather than a row of collided, rotated dates.
268
+ applyEdgeLabels(options) {
269
+ if (!options.edge_labels) return options;
270
+ const categories = options.xaxis?.categories || [];
271
+ const last = categories.length - 1;
272
+ return {
273
+ ...options,
274
+ xaxis: {
275
+ ...options.xaxis || {},
276
+ labels: {
277
+ ...options.xaxis?.labels || {},
278
+ formatter: (value, _timestamp, opts) => {
279
+ const index = typeof opts?.i === "number" ? opts.i : categories.indexOf(value);
280
+ return index === 0 || index === last ? value : "";
281
+ }
282
+ }
283
+ },
284
+ // Apex reuses the axis formatter for the tooltip title, which would blank
285
+ // out every point between the two edges.
286
+ tooltip: {
287
+ ...options.tooltip || {},
288
+ x: {
289
+ ...options.tooltip?.x || {},
290
+ formatter: (value, opts) => categories[opts?.dataPointIndex] ?? value
291
+ }
292
+ }
293
+ };
294
+ }
295
+ applyFormatters(options) {
296
+ const formatter = this.formatterFor(options.format || "number", options.currency);
297
+ if (!formatter) return options;
298
+ const withFormatter = (axis) => {
299
+ if (Array.isArray(axis)) return axis.map(withFormatter);
300
+ const own = axis?.format ? this.formatterFor(axis.format, options.currency) : null;
301
+ return { ...axis || {}, labels: { ...axis?.labels || {}, formatter: own || formatter } };
302
+ };
303
+ if (options.plotOptions?.bar?.horizontal) {
304
+ const timeAxis = options.xaxis?.type === "datetime";
305
+ return {
306
+ ...options,
307
+ ...timeAxis ? {} : { xaxis: withFormatter(options.xaxis) },
308
+ tooltip: { ...options.tooltip || {}, y: { ...options.tooltip?.y || {}, formatter } }
309
+ };
310
+ }
311
+ const formatted = {
312
+ ...options,
313
+ yaxis: withFormatter(options.yaxis),
314
+ tooltip: {
315
+ ...options.tooltip || {},
316
+ y: {
317
+ ...options.tooltip?.y || {},
318
+ formatter
319
+ }
320
+ }
321
+ };
322
+ if (Array.isArray(options.responsive)) {
323
+ formatted.responsive = options.responsive.map((entry) => ({
324
+ ...entry,
325
+ options: {
326
+ ...entry.options || {},
327
+ ...entry.options?.yaxis ? { yaxis: withFormatter(entry.options.yaxis) } : {}
328
+ }
329
+ }));
330
+ }
331
+ return formatted;
332
+ }
333
+ formatterFor(format, currency = "$") {
334
+ switch (format) {
335
+ case "currency":
336
+ return (value) => {
337
+ if (value === null || value === void 0 || isNaN(value)) return value;
338
+ return `${currency}${Number(value).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
339
+ };
340
+ case "percentage":
341
+ return (value) => {
342
+ if (value === null || value === void 0 || isNaN(value)) return value;
343
+ return `${Number(value).toFixed(1)}%`;
344
+ };
345
+ case "human":
346
+ return (value) => {
347
+ if (value === null || value === void 0 || isNaN(value)) return value;
348
+ return this.humanFormat(Number(value));
349
+ };
350
+ case "short_currency":
351
+ return (value) => {
352
+ if (value === null || value === void 0 || isNaN(value)) return value;
353
+ return `${currency}${this.humanFormat(Number(value))}`;
354
+ };
355
+ case "number":
356
+ return (value) => {
357
+ if (value === null || value === void 0 || isNaN(value)) return value;
358
+ return Number(value).toLocaleString("en-US", { maximumFractionDigits: 2 });
359
+ };
360
+ default:
361
+ return null;
362
+ }
363
+ }
364
+ humanFormat(value) {
365
+ if (value === 0) return "0";
366
+ const suffixes = ["", "K", "M", "B", "T"];
367
+ const tier = Math.log10(Math.abs(value)) / 3 | 0;
368
+ if (tier === 0) return `${Math.round(value * 100) / 100}`;
369
+ const scaled = value / Math.pow(10, tier * 3);
370
+ const rounded = Math.round(scaled * 10) / 10;
371
+ return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}${suffixes[tier]}`;
372
+ }
373
+ resolveCssVariables(value) {
374
+ if (typeof value === "string") {
375
+ return this.resolveCssVariable(value);
376
+ }
377
+ if (Array.isArray(value)) {
378
+ return value.map((item) => this.resolveCssVariables(item));
379
+ }
380
+ if (value !== null && typeof value === "object") {
381
+ return Object.entries(value).reduce((result, [key, val]) => {
382
+ result[key] = this.resolveCssVariables(val);
383
+ return result;
384
+ }, {});
385
+ }
386
+ return value;
387
+ }
388
+ resolveCssVariable(value) {
389
+ const match = value.match(/^var\((--[^,]+)(?:,\s*(.+))?\)$/);
390
+ if (!match) return value;
391
+ const variableName = match[1];
392
+ const fallback = match[2];
393
+ const computed = getComputedStyle(this.element).getPropertyValue(variableName).trim();
394
+ return computed || fallback || value;
395
+ }
396
+ bindThemeListeners() {
397
+ this.darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
398
+ this.motionMediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
399
+ this.themeHandler = () => this.rerender();
400
+ this.darkModeMediaQuery.addEventListener("change", this.themeHandler);
401
+ this.motionMediaQuery.addEventListener("change", this.themeHandler);
402
+ this.renderedDarkMode = this.darkMode;
403
+ this.themeObserver = new MutationObserver(() => {
404
+ const dark = this.darkMode;
405
+ if (dark === this.renderedDarkMode) return;
406
+ this.renderedDarkMode = dark;
407
+ this.rerender();
408
+ });
409
+ this.themeObserver.observe(document.documentElement, {
410
+ attributes: true,
411
+ attributeFilter: ["class", "data-theme"]
412
+ });
413
+ }
414
+ unbindThemeListeners() {
415
+ if (this.themeHandler) {
416
+ this.darkModeMediaQuery?.removeEventListener("change", this.themeHandler);
417
+ this.motionMediaQuery?.removeEventListener("change", this.themeHandler);
418
+ }
419
+ this.themeObserver?.disconnect();
420
+ }
421
+ get animationsEnabled() {
422
+ return !(this.motionMediaQuery && this.motionMediaQuery.matches);
423
+ }
424
+ get darkMode() {
425
+ const root = document.documentElement;
426
+ const explicit = root.getAttribute("data-theme");
427
+ if (explicit === "dark") return true;
428
+ if (explicit === "light") return false;
429
+ if (root.classList.contains("dark")) return true;
430
+ return this.darkModeMediaQuery ? this.darkModeMediaQuery.matches : false;
431
+ }
432
+ };
433
+
434
+ // app/javascript/controllers/railsui_chart_filters_controller.js
435
+ import { Controller as Controller2 } from "@hotwired/stimulus";
436
+ var railsui_chart_filters_controller_default = class extends Controller2 {
437
+ submit() {
438
+ if (this.element.requestSubmit) {
439
+ this.element.requestSubmit();
440
+ } else {
441
+ this.element.submit();
442
+ }
443
+ }
444
+ };
445
+
446
+ // app/javascript/controllers/railsui_metric_dialog_controller.js
447
+ import { Controller as Controller3 } from "@hotwired/stimulus";
448
+ var railsui_metric_dialog_controller_default = class extends Controller3 {
449
+ static targets = ["dialog"];
450
+ open() {
451
+ this.dialogTarget.showModal();
452
+ this.dialogTarget.querySelectorAll(".railsui-chart").forEach((chart) => {
453
+ chart.dispatchEvent(new CustomEvent("railsui-chart:refresh"));
454
+ });
455
+ }
456
+ close() {
457
+ this.dialogTarget.close();
458
+ }
459
+ // Clicking the backdrop lands on the dialog element itself; a click anywhere
460
+ // inside lands on a child.
461
+ closeOnBackdrop(event) {
462
+ if (event.target === this.dialogTarget) this.dialogTarget.close();
463
+ }
464
+ };
465
+
466
+ // app/javascript/railsui_charts/index.js
467
+ var RAILSUI_CHART_CONTROLLERS = {
468
+ "railsui-chart": railsui_chart_controller_default,
469
+ "railsui-chart-filters": railsui_chart_filters_controller_default,
470
+ "railsui-metric-dialog": railsui_metric_dialog_controller_default
471
+ };
472
+ function registerRailsuiCharts(application) {
473
+ Object.entries(RAILSUI_CHART_CONTROLLERS).forEach(([identifier, controller]) => {
474
+ application.register(identifier, controller);
475
+ });
476
+ return application;
477
+ }
478
+ var index_default = registerRailsuiCharts;
479
+ export {
480
+ RAILSUI_CHART_CONTROLLERS,
481
+ railsui_chart_controller_default as RailsuiChartController,
482
+ railsui_chart_filters_controller_default as RailsuiChartFiltersController,
483
+ railsui_metric_dialog_controller_default as RailsuiMetricDialogController,
484
+ index_default as default,
485
+ registerRailsuiCharts
486
+ };