railsui_charts 0.1.2 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 90aab15c8c1ef133527f757c7e24344da84a62a190825a13decee8979cf79892
4
- data.tar.gz: db6626712782f3a93d92ea8c149d9fbe27cece328b1cfca4b611269dd06a8c0b
3
+ metadata.gz: f3fa00b0555667e87a913c0341ac9efcff529d54d4eb3b7e6a70fbe12167d1a1
4
+ data.tar.gz: 47b6cc37a82c21c1ccbc1213a8876fa37fa8a2573ef6663629d4e1d09d4cb7fc
5
5
  SHA512:
6
- metadata.gz: 94345b1a73e7cdd1e2760ee58462f6af60396019295cb0a05a5f300120c2d5ed192d64770cb38254abfa959e94ca5ab51ed9353107163e5f2eb75a22e42611a9
7
- data.tar.gz: e89fad7105008595f9de2070044287e1c193a09ab83dd327492a794106259d24783fc30ed580e19c07a40c8a7ea06ed2fbb7ac5780d620af0eafe7ce643da9d8
6
+ metadata.gz: c5140c807d377faf06d02f1da3b1807d71bf023e3551ba74429ad5bc26aed89e8b19746570337b004080e881beced4cb76a7234c486bf1b28cd6b0810eed423c
7
+ data.tar.gz: a38ce24f9ba774e6532df52c87e8c71c6260c2103e679702ecb85e50f06d37b40aa09a7d4b3efd1a6cf74eeaa199b48f00708cc76d90b6eaf1fb2a61789904c0
data/CHANGELOG.md CHANGED
@@ -6,6 +6,49 @@ public API may change between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0]
10
+
11
+ The JavaScript now comes from the gem instead of being copied into the
12
+ application. This is a breaking change to how it is installed; the helpers, the
13
+ chart API and the CSS are untouched.
14
+
15
+ ### Added
16
+
17
+ - An npm package, `@getrailsui/charts`, for applications that bundle their
18
+ JavaScript. It carries the Stimulus controllers and a
19
+ `registerRailsuiCharts(application)` helper that registers each one under the
20
+ identifier the Ruby helpers emit.
21
+ - Importmap support. The engine adds its own pins to the host application's
22
+ importmap and serves a prebuilt, self-contained build, so an importmap app
23
+ needs no package and no copied files. Both worlds use the same import line.
24
+ - `@hotwired/stimulus` and `apexcharts` are declared as peer dependencies rather
25
+ than bundled, so an application keeps one copy of each.
26
+
27
+ ### Changed
28
+
29
+ - **`rails g railsui_charts:install` no longer copies the controllers.** It adds
30
+ the stylesheet import and prints the right next step for a bundled or an
31
+ importmap app.
32
+ - The install generator writes through its own file actions rather than
33
+ `Rails.root`, so it respects `destination_root` — which is also what made the
34
+ generators testable.
35
+
36
+ ### Removed
37
+
38
+ - The duplicate controllers under `lib/generators/.../templates`. They had to be
39
+ kept in sync with the engine's own copies by hand, and had already drifted.
40
+
41
+ ### Upgrading
42
+
43
+ Delete the copied controllers from `app/javascript/controllers` and follow the
44
+ README. Left in place they register the same identifiers a second time, and
45
+ Stimulus keeps the last one without complaint.
46
+
47
+ This is why it changed: a copy is frozen at the version that installed it.
48
+ `bundle update` moved the Ruby and left the JavaScript alone, so 0.1.2's fix for
49
+ empty tooltips on pie, donut and polar area charts reached nobody who did not
50
+ also know to re-copy a file nothing told them about.
51
+
9
52
  ## [0.1.2]
10
53
 
11
54
  ### Fixed
@@ -67,7 +110,8 @@ First release.
67
110
  reachable only by hovering a mark
68
111
  - Animation stops when the reader has asked for reduced motion
69
112
 
70
- [Unreleased]: https://github.com/getrailsui/railsui_charts/compare/v0.1.2...HEAD
113
+ [Unreleased]: https://github.com/getrailsui/railsui_charts/compare/v0.2.0...HEAD
114
+ [0.2.0]: https://github.com/getrailsui/railsui_charts/compare/v0.1.2...v0.2.0
71
115
  [0.1.2]: https://github.com/getrailsui/railsui_charts/compare/v0.1.1...v0.1.2
72
116
  [0.1.1]: https://github.com/getrailsui/railsui_charts/compare/v0.1.0...v0.1.1
73
117
  [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.
@@ -0,0 +1,388 @@
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 || options.chart?.sparkline?.enabled) 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 (!Array.isArray(series[0])) {
105
+ const slice = seriesIndex ?? dataPointIndex;
106
+ const row = { label: "", value: series[slice], color: w.globals.colors[slice], format: rowFormats[slice] || null };
107
+ return this.tooltipMarkup(labels?.[slice], [row], null, format);
108
+ }
109
+ const point = (index) => series[index]?.[dataPointIndex];
110
+ const comparing = series.length === 2 && comparedDates.length > 0;
111
+ const rows = series.map((_, index) => ({
112
+ // In a comparison the two rows are the same metric at different
113
+ // dates, so the date identifies them. Otherwise the name does.
114
+ label: comparing ? index === 0 ? labels?.[dataPointIndex] : comparedDates[dataPointIndex] : w.globals.seriesNames[index],
115
+ value: point(index),
116
+ color: w.globals.colors[index],
117
+ format: rowFormats[index] || null
118
+ }));
119
+ const leadsWithSeries = headingMode ? headingMode === "series" : comparing;
120
+ const heading = leadsWithSeries ? w.globals.seriesNames[0] : labels?.[dataPointIndex];
121
+ const delta = showDelta && comparing ? this.tooltipDelta(point(0), point(1), upIsGood) : null;
122
+ return this.tooltipMarkup(heading, rows, delta, format);
123
+ }
124
+ }
125
+ };
126
+ }
127
+ tooltipDelta(current, previous, upIsGood) {
128
+ if (![current, previous].every((n) => typeof n === "number") || previous === 0) return null;
129
+ const change = (current - previous) / Math.abs(previous) * 100;
130
+ if (!isFinite(change)) return null;
131
+ const rounded = Math.round(change * 100) / 100;
132
+ return {
133
+ text: `${rounded > 0 ? "+" : ""}${rounded}%`,
134
+ tone: rounded === 0 ? "neutral" : rounded > 0 === upIsGood ? "positive" : "negative"
135
+ };
136
+ }
137
+ tooltipMarkup(heading, rows, delta, format) {
138
+ const cells = rows.filter((row) => row.value !== null && row.value !== void 0).map(
139
+ (row) => `
140
+ <tr>
141
+ <th scope="row">
142
+ <span class="railsui-chart-tooltip__key" style="background:${this.escape(row.color)}"></span>
143
+ ${this.escape(row.label)}
144
+ </th>
145
+ <td>${this.escape(this.formatRow(row, format))}</td>
146
+ </tr>`
147
+ ).join("");
148
+ const badge = delta ? `<span class="railsui-chart-tooltip__delta railsui-chart-tooltip__delta--${delta.tone}">${this.escape(delta.text)}</span>` : "";
149
+ return `
150
+ <div class="railsui-chart-tooltip">
151
+ <div class="railsui-chart-tooltip__head">
152
+ <span class="railsui-chart-tooltip__title">${this.escape(heading)}</span>${badge}
153
+ </div>
154
+ <table class="railsui-chart-tooltip__rows">${cells}</table>
155
+ </div>`;
156
+ }
157
+ // A row's own formatter when it has one, the chart's otherwise.
158
+ formatRow(row, fallback) {
159
+ const format = row.format || fallback;
160
+ return format ? format(row.value) : row.value;
161
+ }
162
+ // Series names and category labels come from application data.
163
+ escape(value) {
164
+ return String(value ?? "").replace(/[&<>"']/g, (char) => {
165
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char];
166
+ });
167
+ }
168
+ // Stripe-style cards label only the first and last tick, so the axis reads as
169
+ // a range rather than a row of collided, rotated dates.
170
+ applyEdgeLabels(options) {
171
+ if (!options.edge_labels) return options;
172
+ const categories = options.xaxis?.categories || [];
173
+ const last = categories.length - 1;
174
+ return {
175
+ ...options,
176
+ xaxis: {
177
+ ...options.xaxis || {},
178
+ labels: {
179
+ ...options.xaxis?.labels || {},
180
+ formatter: (value, _timestamp, opts) => {
181
+ const index = typeof opts?.i === "number" ? opts.i : categories.indexOf(value);
182
+ return index === 0 || index === last ? value : "";
183
+ }
184
+ }
185
+ },
186
+ // Apex reuses the axis formatter for the tooltip title, which would blank
187
+ // out every point between the two edges.
188
+ tooltip: {
189
+ ...options.tooltip || {},
190
+ x: {
191
+ ...options.tooltip?.x || {},
192
+ formatter: (value, opts) => categories[opts?.dataPointIndex] ?? value
193
+ }
194
+ }
195
+ };
196
+ }
197
+ applyFormatters(options) {
198
+ const formatter = this.formatterFor(options.format || "number", options.currency);
199
+ if (!formatter) return options;
200
+ const withFormatter = (axis) => {
201
+ if (Array.isArray(axis)) return axis.map(withFormatter);
202
+ const own = axis?.format ? this.formatterFor(axis.format, options.currency) : null;
203
+ return { ...axis || {}, labels: { ...axis?.labels || {}, formatter: own || formatter } };
204
+ };
205
+ if (options.plotOptions?.bar?.horizontal) {
206
+ const timeAxis = options.xaxis?.type === "datetime";
207
+ return {
208
+ ...options,
209
+ ...timeAxis ? {} : { xaxis: withFormatter(options.xaxis) },
210
+ tooltip: { ...options.tooltip || {}, y: { ...options.tooltip?.y || {}, formatter } }
211
+ };
212
+ }
213
+ const formatted = {
214
+ ...options,
215
+ yaxis: withFormatter(options.yaxis),
216
+ tooltip: {
217
+ ...options.tooltip || {},
218
+ y: {
219
+ ...options.tooltip?.y || {},
220
+ formatter
221
+ }
222
+ }
223
+ };
224
+ if (Array.isArray(options.responsive)) {
225
+ formatted.responsive = options.responsive.map((entry) => ({
226
+ ...entry,
227
+ options: {
228
+ ...entry.options || {},
229
+ ...entry.options?.yaxis ? { yaxis: withFormatter(entry.options.yaxis) } : {}
230
+ }
231
+ }));
232
+ }
233
+ return formatted;
234
+ }
235
+ formatterFor(format, currency = "$") {
236
+ switch (format) {
237
+ case "currency":
238
+ return (value) => {
239
+ if (value === null || value === void 0 || isNaN(value)) return value;
240
+ return `${currency}${Number(value).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
241
+ };
242
+ case "percentage":
243
+ return (value) => {
244
+ if (value === null || value === void 0 || isNaN(value)) return value;
245
+ return `${Number(value).toFixed(1)}%`;
246
+ };
247
+ case "human":
248
+ return (value) => {
249
+ if (value === null || value === void 0 || isNaN(value)) return value;
250
+ return this.humanFormat(Number(value));
251
+ };
252
+ case "short_currency":
253
+ return (value) => {
254
+ if (value === null || value === void 0 || isNaN(value)) return value;
255
+ return `${currency}${this.humanFormat(Number(value))}`;
256
+ };
257
+ case "number":
258
+ return (value) => {
259
+ if (value === null || value === void 0 || isNaN(value)) return value;
260
+ return Number(value).toLocaleString("en-US", { maximumFractionDigits: 2 });
261
+ };
262
+ default:
263
+ return null;
264
+ }
265
+ }
266
+ humanFormat(value) {
267
+ if (value === 0) return "0";
268
+ const suffixes = ["", "K", "M", "B", "T"];
269
+ const tier = Math.log10(Math.abs(value)) / 3 | 0;
270
+ if (tier === 0) return `${Math.round(value * 100) / 100}`;
271
+ const scaled = value / Math.pow(10, tier * 3);
272
+ const rounded = Math.round(scaled * 10) / 10;
273
+ return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}${suffixes[tier]}`;
274
+ }
275
+ resolveCssVariables(value) {
276
+ if (typeof value === "string") {
277
+ return this.resolveCssVariable(value);
278
+ }
279
+ if (Array.isArray(value)) {
280
+ return value.map((item) => this.resolveCssVariables(item));
281
+ }
282
+ if (value !== null && typeof value === "object") {
283
+ return Object.entries(value).reduce((result, [key, val]) => {
284
+ result[key] = this.resolveCssVariables(val);
285
+ return result;
286
+ }, {});
287
+ }
288
+ return value;
289
+ }
290
+ resolveCssVariable(value) {
291
+ const match = value.match(/^var\((--[^,]+)(?:,\s*(.+))?\)$/);
292
+ if (!match) return value;
293
+ const variableName = match[1];
294
+ const fallback = match[2];
295
+ const computed = getComputedStyle(this.element).getPropertyValue(variableName).trim();
296
+ return computed || fallback || value;
297
+ }
298
+ bindThemeListeners() {
299
+ this.darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
300
+ this.motionMediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
301
+ this.themeHandler = () => this.rerender();
302
+ this.darkModeMediaQuery.addEventListener("change", this.themeHandler);
303
+ this.motionMediaQuery.addEventListener("change", this.themeHandler);
304
+ this.renderedDarkMode = this.darkMode;
305
+ this.themeObserver = new MutationObserver(() => {
306
+ const dark = this.darkMode;
307
+ if (dark === this.renderedDarkMode) return;
308
+ this.renderedDarkMode = dark;
309
+ this.rerender();
310
+ });
311
+ this.themeObserver.observe(document.documentElement, {
312
+ attributes: true,
313
+ attributeFilter: ["class", "data-theme"]
314
+ });
315
+ }
316
+ unbindThemeListeners() {
317
+ if (this.themeHandler) {
318
+ this.darkModeMediaQuery?.removeEventListener("change", this.themeHandler);
319
+ this.motionMediaQuery?.removeEventListener("change", this.themeHandler);
320
+ }
321
+ this.themeObserver?.disconnect();
322
+ }
323
+ get animationsEnabled() {
324
+ return !(this.motionMediaQuery && this.motionMediaQuery.matches);
325
+ }
326
+ get darkMode() {
327
+ const root = document.documentElement;
328
+ const explicit = root.getAttribute("data-theme");
329
+ if (explicit === "dark") return true;
330
+ if (explicit === "light") return false;
331
+ if (root.classList.contains("dark")) return true;
332
+ return this.darkModeMediaQuery ? this.darkModeMediaQuery.matches : false;
333
+ }
334
+ };
335
+
336
+ // app/javascript/controllers/railsui_chart_filters_controller.js
337
+ import { Controller as Controller2 } from "@hotwired/stimulus";
338
+ var railsui_chart_filters_controller_default = class extends Controller2 {
339
+ submit() {
340
+ if (this.element.requestSubmit) {
341
+ this.element.requestSubmit();
342
+ } else {
343
+ this.element.submit();
344
+ }
345
+ }
346
+ };
347
+
348
+ // app/javascript/controllers/railsui_metric_dialog_controller.js
349
+ import { Controller as Controller3 } from "@hotwired/stimulus";
350
+ var railsui_metric_dialog_controller_default = class extends Controller3 {
351
+ static targets = ["dialog"];
352
+ open() {
353
+ this.dialogTarget.showModal();
354
+ this.dialogTarget.querySelectorAll(".railsui-chart").forEach((chart) => {
355
+ chart.dispatchEvent(new CustomEvent("railsui-chart:refresh"));
356
+ });
357
+ }
358
+ close() {
359
+ this.dialogTarget.close();
360
+ }
361
+ // Clicking the backdrop lands on the dialog element itself; a click anywhere
362
+ // inside lands on a child.
363
+ closeOnBackdrop(event) {
364
+ if (event.target === this.dialogTarget) this.dialogTarget.close();
365
+ }
366
+ };
367
+
368
+ // app/javascript/railsui_charts/index.js
369
+ var RAILSUI_CHART_CONTROLLERS = {
370
+ "railsui-chart": railsui_chart_controller_default,
371
+ "railsui-chart-filters": railsui_chart_filters_controller_default,
372
+ "railsui-metric-dialog": railsui_metric_dialog_controller_default
373
+ };
374
+ function registerRailsuiCharts(application) {
375
+ Object.entries(RAILSUI_CHART_CONTROLLERS).forEach(([identifier, controller]) => {
376
+ application.register(identifier, controller);
377
+ });
378
+ return application;
379
+ }
380
+ var index_default = registerRailsuiCharts;
381
+ export {
382
+ RAILSUI_CHART_CONTROLLERS,
383
+ railsui_chart_controller_default as RailsuiChartController,
384
+ railsui_chart_filters_controller_default as RailsuiChartFiltersController,
385
+ railsui_metric_dialog_controller_default as RailsuiMetricDialogController,
386
+ index_default as default,
387
+ registerRailsuiCharts
388
+ };
@@ -0,0 +1,32 @@
1
+ // The package entry point.
2
+ //
3
+ // The controllers used to be copied into each application by a generator, which
4
+ // meant `bundle update` moved the Ruby and left the JavaScript on whatever
5
+ // version was installed. Importing them from here — over npm for a bundled app,
6
+ // over the asset pipeline for an importmap one — is what makes an upgrade
7
+ // actually arrive.
8
+
9
+ import RailsuiChartController from "../controllers/railsui_chart_controller.js"
10
+ import RailsuiChartFiltersController from "../controllers/railsui_chart_filters_controller.js"
11
+ import RailsuiMetricDialogController from "../controllers/railsui_metric_dialog_controller.js"
12
+
13
+ export { RailsuiChartController, RailsuiChartFiltersController, RailsuiMetricDialogController }
14
+
15
+ // The identifiers the Ruby helpers emit. Registering them by hand means three
16
+ // chances to typo a name that fails silently — a chart simply never draws, with
17
+ // nothing in the console to say why.
18
+ export const RAILSUI_CHART_CONTROLLERS = {
19
+ "railsui-chart": RailsuiChartController,
20
+ "railsui-chart-filters": RailsuiChartFiltersController,
21
+ "railsui-metric-dialog": RailsuiMetricDialogController
22
+ }
23
+
24
+ export function registerRailsuiCharts(application) {
25
+ Object.entries(RAILSUI_CHART_CONTROLLERS).forEach(([identifier, controller]) => {
26
+ application.register(identifier, controller)
27
+ })
28
+
29
+ return application
30
+ }
31
+
32
+ export default registerRailsuiCharts
@@ -0,0 +1,12 @@
1
+ # Pins for applications using importmap-rails.
2
+ #
3
+ # The engine adds this file to the app's importmap paths, so an importmap app
4
+ # gets the same bare specifier a bundled app uses:
5
+ #
6
+ # import { registerRailsuiCharts } from "@getrailsui/charts"
7
+ #
8
+ # It points at a prebuilt, self-contained file rather than at the individual
9
+ # controllers. Sprockets digests every asset separately, so the relative imports
10
+ # between the source files would not resolve once served — the browser would ask
11
+ # for an undigested path and get a 404 with the chart simply never appearing.
12
+ pin "@getrailsui/charts", to: "railsui_charts.js", preload: true
@@ -3,50 +3,65 @@ require "rails/generators"
3
3
  module RailsuiCharts
4
4
  module Generators
5
5
  class InstallGenerator < Rails::Generators::Base
6
- source_root File.expand_path("templates", __dir__)
7
-
6
+ # The JavaScript is no longer copied. It used to be, and that meant
7
+ # `bundle update` moved the Ruby while the controllers stayed on whatever
8
+ # version was installed — a fix could ship and reach nobody. Bundled apps
9
+ # take it from npm, importmap apps from the pin this engine adds, and both
10
+ # follow the gem from then on.
8
11
  def add_css_import
9
- application_css = Rails.root.join("app/assets/tailwind/application.css")
10
-
11
- if File.exist?(application_css)
12
- content = File.read(application_css)
13
- import_statement = '@import "../../stylesheets/railsui_charts";'
14
-
15
- unless content.include?(import_statement)
16
- File.write(application_css, "#{content.strip}\n#{import_statement}\n")
17
- say "✓ Added RailsUI Charts CSS import to app/assets/tailwind/application.css", :green
18
- else
19
- say "✓ RailsUI Charts CSS import already present", :green
20
- end
12
+ path = "app/assets/tailwind/application.css"
13
+ import_statement = %q(@import "../../stylesheets/railsui_charts";)
14
+ full_path = File.join(destination_root, path)
15
+
16
+ unless File.exist?(full_path)
17
+ say "⚠️ #{path} not found. Add this import manually:", :yellow
18
+ say " #{import_statement}"
19
+ return
20
+ end
21
+
22
+ if File.read(full_path).include?(import_statement)
23
+ say "✓ RailsUI Charts CSS import already present", :green
21
24
  else
22
- say "⚠️ app/assets/tailwind/application.css not found. Add this import manually:", :yellow
23
- say ' @import "../../stylesheets/railsui_charts";'
25
+ append_to_file path, "#{import_statement}\n"
26
+ say "✓ Added RailsUI Charts CSS import to #{path}", :green
24
27
  end
25
28
  end
26
29
 
27
- CONTROLLERS = %w[
28
- railsui_chart_controller.js
29
- railsui_chart_filters_controller.js
30
- railsui_metric_dialog_controller.js
31
- ].freeze
30
+ def print_next_steps
31
+ say ""
32
+ say "RailsUI Charts #{RailsuiCharts::VERSION} installed.", :green
33
+ say ""
32
34
 
33
- def copy_stimulus_controllers
34
- CONTROLLERS.each do |controller|
35
- copy_file controller, "app/javascript/controllers/#{controller}"
35
+ if importmap?
36
+ say "Your importmap already has the controllers — this engine pins them.", :cyan
37
+ say "Register them in app/javascript/controllers/index.js:", :cyan
38
+ else
39
+ say "Add the JavaScript package and ApexCharts:", :cyan
40
+ say " yarn add @getrailsui/charts apexcharts", :cyan
41
+ say ""
42
+ say "Then register the controllers in app/javascript/controllers/index.js:", :cyan
36
43
  end
37
- end
38
44
 
39
- def print_next_steps
40
45
  say ""
41
- say "RailsUI Charts installed.", :green
46
+ say ' import { registerRailsuiCharts } from "@getrailsui/charts"', :cyan
47
+ say " registerRailsuiCharts(application)", :cyan
42
48
  say ""
43
- say "Next steps:", :cyan
44
- say " 1. Add the ApexCharts dependency for your JS setup:", :cyan
45
- say " Build mode: yarn add apexcharts", :cyan
46
- say " No-build: pin \"apexcharts\", to: \"https://esm.sh/apexcharts@3.45.2\"", :cyan
47
- say " 2. Use <%= railsui_chart data, type: :area %> in your views.", :cyan
49
+
50
+ if importmap?
51
+ say "ApexCharts is a peer dependency; pin it too:", :cyan
52
+ say ' pin "apexcharts", to: "https://esm.sh/apexcharts@3.45.2"', :cyan
53
+ say ""
54
+ end
55
+
56
+ say "Then use <%= railsui_chart data, type: :area %> in your views.", :cyan
48
57
  say ""
49
58
  end
59
+
60
+ private
61
+
62
+ def importmap?
63
+ File.exist?(File.join(destination_root, "config/importmap.rb"))
64
+ end
50
65
  end
51
66
  end
52
67
  end
@@ -13,7 +13,23 @@ module RailsuiCharts
13
13
  end
14
14
 
15
15
  initializer "railsui_charts.assets" do |app|
16
+ next unless app.config.respond_to?(:assets)
17
+
16
18
  app.config.assets.precompile << "railsui_charts.css"
19
+
20
+ # The built controllers, served from the gem so an importmap application
21
+ # can pin them instead of holding a copy that bundle update never moves.
22
+ app.config.assets.paths << root.join("app/assets/javascripts")
23
+ app.config.assets.precompile << "railsui_charts.js"
24
+ end
25
+
26
+ # Adds this gem's pins to the host app's importmap. `before` matters:
27
+ # importmap-rails reads the collected paths in its own initializer, and a
28
+ # pin added afterwards is simply never seen.
29
+ initializer "railsui_charts.importmap", before: "importmap" do |app|
30
+ next unless app.config.respond_to?(:importmap)
31
+
32
+ app.config.importmap.paths << root.join("config/importmap.rb")
17
33
  end
18
34
  end
19
35
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsuiCharts
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
data/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@getrailsui/charts",
3
+ "version": "0.2.0",
4
+ "description": "Rails-native chart components built on ApexCharts. Stimulus controllers for the railsui_charts gem.",
5
+ "author": "Andy Leverenz <railsui@justalever.com>",
6
+ "license": "MIT",
7
+ "homepage": "https://railsui.com/charts",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/getrailsui/railsui_charts.git"
11
+ },
12
+ "type": "module",
13
+ "main": "app/javascript/railsui_charts/index.js",
14
+ "module": "app/javascript/railsui_charts/index.js",
15
+ "exports": {
16
+ ".": "./app/javascript/railsui_charts/index.js",
17
+ "./controllers/*": "./app/javascript/controllers/*"
18
+ },
19
+ "files": [
20
+ "app/javascript",
21
+ "app/assets/javascripts",
22
+ "README.md",
23
+ "LICENSE.md",
24
+ "CHANGELOG.md"
25
+ ],
26
+ "scripts": {
27
+ "build": "esbuild app/javascript/railsui_charts/index.js --bundle --format=esm --external:@hotwired/stimulus --external:apexcharts --outfile=app/assets/javascripts/railsui_charts.js"
28
+ },
29
+ "peerDependencies": {
30
+ "@hotwired/stimulus": ">=3.0.0",
31
+ "apexcharts": ">=3.45.0"
32
+ },
33
+ "devDependencies": {
34
+ "esbuild": "^0.25.0"
35
+ },
36
+ "sideEffects": false
37
+ }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railsui_charts
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Leverenz
@@ -35,17 +35,17 @@ files:
35
35
  - LICENSE.md
36
36
  - README.md
37
37
  - Rakefile
38
+ - app/assets/javascripts/railsui_charts.js
38
39
  - app/assets/stylesheets/railsui_charts.css
39
40
  - app/controllers/railsui_charts/demo_controller.rb
40
41
  - app/javascript/controllers/railsui_chart_controller.js
41
42
  - app/javascript/controllers/railsui_chart_filters_controller.js
42
43
  - app/javascript/controllers/railsui_metric_dialog_controller.js
44
+ - app/javascript/railsui_charts/index.js
43
45
  - app/views/railsui_charts/demo/index.html.erb
46
+ - config/importmap.rb
44
47
  - config/routes.rb
45
48
  - lib/generators/railsui_charts/install/install_generator.rb
46
- - lib/generators/railsui_charts/install/templates/railsui_chart_controller.js
47
- - lib/generators/railsui_charts/install/templates/railsui_chart_filters_controller.js
48
- - lib/generators/railsui_charts/install/templates/railsui_metric_dialog_controller.js
49
49
  - lib/railsui_charts.rb
50
50
  - lib/railsui_charts/apex_options_builder.rb
51
51
  - lib/railsui_charts/chart_helper.rb
@@ -56,6 +56,7 @@ files:
56
56
  - lib/railsui_charts/interval.rb
57
57
  - lib/railsui_charts/metric_helper.rb
58
58
  - lib/railsui_charts/version.rb
59
+ - package.json
59
60
  homepage: https://railsui.com/charts
60
61
  licenses:
61
62
  - MIT
@@ -1,453 +0,0 @@
1
- import { Controller } from "@hotwired/stimulus"
2
- import ApexCharts from "apexcharts"
3
-
4
- export default class extends Controller {
5
- static values = { options: Object }
6
-
7
- connect() {
8
- // Bind before the first render: the theme getter reads the media query, so
9
- // rendering first paints every chart light on a dark OS.
10
- this.bindThemeListeners()
11
-
12
- // Apex measures the element as it renders. Anything hidden at connect — a
13
- // closed dialog, a collapsed panel — measures zero and draws nothing, so
14
- // there is no point building it yet. Whatever reveals it says so, and the
15
- // chart lays itself out then.
16
- this.refresh = this.refresh.bind(this)
17
- this.element.addEventListener("railsui-chart:refresh", this.refresh)
18
-
19
- this.watchViewport()
20
- }
21
-
22
- disconnect() {
23
- this.unwatchViewport()
24
- this.element.removeEventListener("railsui-chart:refresh", this.refresh)
25
- this.destroy()
26
- this.unbindThemeListeners()
27
- }
28
-
29
- // Building every chart at once is work the reader has not asked for, and a
30
- // page carrying a dozen of them spends the first seconds laying out charts
31
- // nobody is looking at. Each one waits until it is nearly on screen; the
32
- // server reserves its height so nothing shifts when it arrives.
33
- watchViewport() {
34
- if (!("IntersectionObserver" in window)) {
35
- if (this.measurable) this.render()
36
- return
37
- }
38
-
39
- this.viewportObserver = new IntersectionObserver(
40
- (entries) => {
41
- if (!entries.some((entry) => entry.isIntersecting)) return
42
-
43
- this.unwatchViewport()
44
- if (this.measurable) this.render()
45
- },
46
- // Start early enough that it is drawn by the time it is scrolled to.
47
- { rootMargin: "300px 0px" }
48
- )
49
-
50
- this.viewportObserver.observe(this.element)
51
- }
52
-
53
- unwatchViewport() {
54
- this.viewportObserver?.disconnect()
55
- this.viewportObserver = null
56
- }
57
-
58
- // An explicit reveal outranks the viewport check — a dialog opening means
59
- // draw it now.
60
- refresh() {
61
- this.unwatchViewport()
62
- if (this.measurable) this.render()
63
- }
64
-
65
- rerender() {
66
- if (this.chart) this.render()
67
- }
68
-
69
- // Anything inside a `display: none` subtree — a closed <dialog>, a hidden
70
- // tab panel — reports no client rects.
71
- get measurable() {
72
- return this.element.getClientRects().length > 0
73
- }
74
-
75
- render() {
76
- this.destroy()
77
- this.chart = new ApexCharts(this.element, this.resolvedOptions())
78
- this.chart.render()
79
- }
80
-
81
- destroy() {
82
- if (this.chart) {
83
- this.chart.destroy()
84
- this.chart = null
85
- }
86
- }
87
-
88
- resolvedOptions() {
89
- const options = this.resolveCssVariables({
90
- ...this.optionsValue,
91
- chart: {
92
- ...(this.optionsValue.chart || {}),
93
- animations: {
94
- ...(this.optionsValue.chart?.animations || {}),
95
- enabled: this.animationsEnabled
96
- }
97
- },
98
- theme: {
99
- ...this.optionsValue.theme,
100
- // Last word: the server ships a static `light` default it cannot know
101
- // better than, so spreading it after would pin every chart to light.
102
- mode: this.darkMode ? "dark" : "light"
103
- }
104
- })
105
-
106
- return this.applyTooltip(this.applyEdgeLabels(this.applyFormatters(options)))
107
- }
108
-
109
- // Apex's stock tooltip is a dark slab whatever the page is doing. This one is
110
- // built from the same CSS variables as everything else, so it follows the
111
- // theme, and it leads with what changed rather than with the date.
112
- applyTooltip(options) {
113
- // `tooltip_style: false` hands the tooltip back to Apex; passing your own
114
- // `tooltip.custom` also wins.
115
- if (options.tooltip_style === false) return options
116
- if (options.tooltip?.custom || options.chart?.sparkline?.enabled) return options
117
-
118
- const format = this.formatterFor(options.format || "number", options.currency)
119
- // One formatter per series on a combo, so a currency row and a percentage
120
- // row in the same tooltip each read in their own units.
121
- const rowFormats = (options.series_formats || []).map((name) => this.formatterFor(name, options.currency))
122
- const comparedDates = options.compare_categories || []
123
- const upIsGood = options.trend_up_is_good !== false
124
- const showDelta = options.tooltip_delta !== false
125
- const headingMode = options.tooltip_heading
126
-
127
- return {
128
- ...options,
129
- tooltip: {
130
- ...(options.tooltip || {}),
131
- custom: ({ series, seriesIndex, dataPointIndex, w }) => {
132
- const labels = w.globals.categoryLabels?.length ? w.globals.categoryLabels : w.globals.labels
133
-
134
- // A pie, donut or polar area hands back one number per slice rather
135
- // than one array per series, and names the hovered slice with
136
- // seriesIndex — dataPointIndex means nothing there. Read as a
137
- // cartesian series, every value came out undefined, and since rows
138
- // without a value are dropped the tooltip rendered as an empty box.
139
- if (!Array.isArray(series[0])) {
140
- const slice = seriesIndex ?? dataPointIndex
141
- // No row label: the slice's name is already the heading, and
142
- // printing it twice in a two-line tooltip reads as a mistake.
143
- const row = { label: "", value: series[slice], color: w.globals.colors[slice], format: rowFormats[slice] || null }
144
-
145
- return this.tooltipMarkup(labels?.[slice], [row], null, format)
146
- }
147
-
148
- const point = (index) => series[index]?.[dataPointIndex]
149
- const comparing = series.length === 2 && comparedDates.length > 0
150
-
151
- const rows = series.map((_, index) => ({
152
- // In a comparison the two rows are the same metric at different
153
- // dates, so the date identifies them. Otherwise the name does.
154
- label: comparing
155
- ? (index === 0 ? labels?.[dataPointIndex] : comparedDates[dataPointIndex])
156
- : w.globals.seriesNames[index],
157
- value: point(index),
158
- color: w.globals.colors[index],
159
- format: rowFormats[index] || null
160
- }))
161
-
162
- // Auto: a comparison leads with the metric, since the rows carry the
163
- // dates. Anything else leads with the point being hovered.
164
- const leadsWithSeries = headingMode ? headingMode === "series" : comparing
165
- const heading = leadsWithSeries ? w.globals.seriesNames[0] : labels?.[dataPointIndex]
166
- const delta = showDelta && comparing ? this.tooltipDelta(point(0), point(1), upIsGood) : null
167
-
168
- return this.tooltipMarkup(heading, rows, delta, format)
169
- }
170
- }
171
- }
172
- }
173
-
174
- tooltipDelta(current, previous, upIsGood) {
175
- if (![current, previous].every((n) => typeof n === "number") || previous === 0) return null
176
-
177
- const change = ((current - previous) / Math.abs(previous)) * 100
178
- if (!isFinite(change)) return null
179
-
180
- const rounded = Math.round(change * 100) / 100
181
- return {
182
- text: `${rounded > 0 ? "+" : ""}${rounded}%`,
183
- tone: rounded === 0 ? "neutral" : (rounded > 0) === upIsGood ? "positive" : "negative"
184
- }
185
- }
186
-
187
- tooltipMarkup(heading, rows, delta, format) {
188
- const cells = rows
189
- .filter((row) => row.value !== null && row.value !== undefined)
190
- .map(
191
- (row) => `
192
- <tr>
193
- <th scope="row">
194
- <span class="railsui-chart-tooltip__key" style="background:${this.escape(row.color)}"></span>
195
- ${this.escape(row.label)}
196
- </th>
197
- <td>${this.escape(this.formatRow(row, format))}</td>
198
- </tr>`
199
- )
200
- .join("")
201
-
202
- const badge = delta
203
- ? `<span class="railsui-chart-tooltip__delta railsui-chart-tooltip__delta--${delta.tone}">${this.escape(delta.text)}</span>`
204
- : ""
205
-
206
- return `
207
- <div class="railsui-chart-tooltip">
208
- <div class="railsui-chart-tooltip__head">
209
- <span class="railsui-chart-tooltip__title">${this.escape(heading)}</span>${badge}
210
- </div>
211
- <table class="railsui-chart-tooltip__rows">${cells}</table>
212
- </div>`
213
- }
214
-
215
- // A row's own formatter when it has one, the chart's otherwise.
216
- formatRow(row, fallback) {
217
- const format = row.format || fallback
218
- return format ? format(row.value) : row.value
219
- }
220
-
221
- // Series names and category labels come from application data.
222
- escape(value) {
223
- return String(value ?? "").replace(/[&<>"']/g, (char) => {
224
- return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]
225
- })
226
- }
227
-
228
- // Stripe-style cards label only the first and last tick, so the axis reads as
229
- // a range rather than a row of collided, rotated dates.
230
- applyEdgeLabels(options) {
231
- if (!options.edge_labels) return options
232
-
233
- const categories = options.xaxis?.categories || []
234
- const last = categories.length - 1
235
-
236
- return {
237
- ...options,
238
- xaxis: {
239
- ...(options.xaxis || {}),
240
- labels: {
241
- ...(options.xaxis?.labels || {}),
242
- formatter: (value, _timestamp, opts) => {
243
- const index = typeof opts?.i === "number" ? opts.i : categories.indexOf(value)
244
- return index === 0 || index === last ? value : ""
245
- }
246
- }
247
- },
248
- // Apex reuses the axis formatter for the tooltip title, which would blank
249
- // out every point between the two edges.
250
- tooltip: {
251
- ...(options.tooltip || {}),
252
- x: {
253
- ...(options.tooltip?.x || {}),
254
- formatter: (value, opts) => categories[opts?.dataPointIndex] ?? value
255
- }
256
- }
257
- }
258
- }
259
-
260
- applyFormatters(options) {
261
- const formatter = this.formatterFor(options.format || "number", options.currency)
262
- if (!formatter) return options
263
-
264
- const withFormatter = (axis) => {
265
- if (Array.isArray(axis)) return axis.map(withFormatter)
266
-
267
- // An axis may name its own format. A combo measures money on one side
268
- // and a percentage on the other, and one formatter across both dresses
269
- // one of the two scales in the wrong units.
270
- const own = axis?.format ? this.formatterFor(axis.format, options.currency) : null
271
-
272
- return { ...(axis || {}), labels: { ...(axis?.labels || {}), formatter: own || formatter } }
273
- }
274
-
275
- // A horizontal bar puts its values along x and its categories up y, so
276
- // formatting the y-axis there would dress the labels and leave the numbers
277
- // bare.
278
- if (options.plotOptions?.bar?.horizontal) {
279
- // Unless it is a time axis. A timeline's x values are milliseconds, and a
280
- // number formatter over the top turns every tick into "1,786,380,000,000"
281
- // where a date belongs. Apex formats a datetime axis from the date
282
- // itself, so the right move is to leave it alone.
283
- const timeAxis = options.xaxis?.type === "datetime"
284
-
285
- return {
286
- ...options,
287
- ...(timeAxis ? {} : { xaxis: withFormatter(options.xaxis) }),
288
- tooltip: { ...(options.tooltip || {}), y: { ...(options.tooltip?.y || {}), formatter: formatter } }
289
- }
290
- }
291
-
292
- const formatted = {
293
- ...options,
294
- yaxis: withFormatter(options.yaxis),
295
- tooltip: {
296
- ...(options.tooltip || {}),
297
- y: {
298
- ...(options.tooltip?.y || {}),
299
- formatter: formatter
300
- }
301
- }
302
- }
303
-
304
- // Only when there is one. Apex reads `responsive` as a list, and writing
305
- // the key back as undefined is not the same as leaving it out — it finds
306
- // the key, walks it, and throws before anything is drawn. A sparkline
307
- // never gets breakpoints, so every one of them hit this and rendered as
308
- // an empty element with no error in the console.
309
- if (Array.isArray(options.responsive)) {
310
- // Breakpoint overrides replace the axis rather than merging into it, so
311
- // an unformatted mobile axis is the default unless the formatter is
312
- // planted in each one too.
313
- formatted.responsive = options.responsive.map((entry) => ({
314
- ...entry,
315
- options: {
316
- ...(entry.options || {}),
317
- ...(entry.options?.yaxis ? { yaxis: withFormatter(entry.options.yaxis) } : {})
318
- }
319
- }))
320
- }
321
-
322
- return formatted
323
- }
324
-
325
- formatterFor(format, currency = "$") {
326
- switch (format) {
327
- case "currency":
328
- return (value) => {
329
- if (value === null || value === undefined || isNaN(value)) return value
330
- return `${currency}${Number(value).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
331
- }
332
- case "percentage":
333
- return (value) => {
334
- if (value === null || value === undefined || isNaN(value)) return value
335
- return `${Number(value).toFixed(1)}%`
336
- }
337
- case "human":
338
- return (value) => {
339
- if (value === null || value === undefined || isNaN(value)) return value
340
- return this.humanFormat(Number(value))
341
- }
342
- case "short_currency":
343
- return (value) => {
344
- if (value === null || value === undefined || isNaN(value)) return value
345
- return `${currency}${this.humanFormat(Number(value))}`
346
- }
347
- case "number":
348
- // Computed series arrive as floats, so an unformatted axis renders
349
- // "860.0000000000000". Delimited and trimmed by default.
350
- return (value) => {
351
- if (value === null || value === undefined || isNaN(value)) return value
352
- return Number(value).toLocaleString("en-US", { maximumFractionDigits: 2 })
353
- }
354
- default:
355
- return null
356
- }
357
- }
358
-
359
- humanFormat(value) {
360
- if (value === 0) return "0"
361
-
362
- const suffixes = ["", "K", "M", "B", "T"]
363
- const tier = Math.log10(Math.abs(value)) / 3 | 0
364
- if (tier === 0) return `${Math.round(value * 100) / 100}`
365
-
366
- const scaled = value / Math.pow(10, tier * 3)
367
- // 2.0K reads worse than 2K; only keep the decimal when it carries meaning.
368
- const rounded = Math.round(scaled * 10) / 10
369
- return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}${suffixes[tier]}`
370
- }
371
-
372
- resolveCssVariables(value) {
373
- if (typeof value === "string") {
374
- return this.resolveCssVariable(value)
375
- }
376
-
377
- if (Array.isArray(value)) {
378
- return value.map((item) => this.resolveCssVariables(item))
379
- }
380
-
381
- if (value !== null && typeof value === "object") {
382
- return Object.entries(value).reduce((result, [key, val]) => {
383
- result[key] = this.resolveCssVariables(val)
384
- return result
385
- }, {})
386
- }
387
-
388
- return value
389
- }
390
-
391
- resolveCssVariable(value) {
392
- const match = value.match(/^var\((--[^,]+)(?:,\s*(.+))?\)$/)
393
- if (!match) return value
394
-
395
- const variableName = match[1]
396
- const fallback = match[2]
397
- // Read from the chart's own element so a scoped theme (a dark panel inside
398
- // a light page) resolves against the surface it actually sits on.
399
- const computed = getComputedStyle(this.element).getPropertyValue(variableName).trim()
400
-
401
- return computed || fallback || value
402
- }
403
-
404
- bindThemeListeners() {
405
- this.darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
406
- this.motionMediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
407
- // Only redraw what is already drawn. A chart still waiting for the
408
- // viewport should keep waiting rather than be pulled forward by a theme
409
- // change it is not on screen for.
410
- this.themeHandler = () => this.rerender()
411
-
412
- this.darkModeMediaQuery.addEventListener("change", this.themeHandler)
413
- this.motionMediaQuery.addEventListener("change", this.themeHandler)
414
-
415
- // Class- and attribute-based theme toggles never fire a media query event,
416
- // so watch the root element for the swap too.
417
- this.renderedDarkMode = this.darkMode
418
- this.themeObserver = new MutationObserver(() => {
419
- const dark = this.darkMode
420
- if (dark === this.renderedDarkMode) return
421
-
422
- this.renderedDarkMode = dark
423
- this.rerender()
424
- })
425
- this.themeObserver.observe(document.documentElement, {
426
- attributes: true,
427
- attributeFilter: ["class", "data-theme"]
428
- })
429
- }
430
-
431
- unbindThemeListeners() {
432
- if (this.themeHandler) {
433
- this.darkModeMediaQuery?.removeEventListener("change", this.themeHandler)
434
- this.motionMediaQuery?.removeEventListener("change", this.themeHandler)
435
- }
436
- this.themeObserver?.disconnect()
437
- }
438
-
439
- get animationsEnabled() {
440
- return !(this.motionMediaQuery && this.motionMediaQuery.matches)
441
- }
442
-
443
- get darkMode() {
444
- const root = document.documentElement
445
- const explicit = root.getAttribute("data-theme")
446
-
447
- if (explicit === "dark") return true
448
- if (explicit === "light") return false
449
- if (root.classList.contains("dark")) return true
450
-
451
- return this.darkModeMediaQuery ? this.darkModeMediaQuery.matches : false
452
- }
453
- }
@@ -1,14 +0,0 @@
1
- import { Controller } from "@hotwired/stimulus"
2
-
3
- // Submits the filter row as soon as a control changes, so the whole view
4
- // re-renders against one slice. The form is a plain GET, so every slice stays
5
- // linkable and the page still works with this controller absent.
6
- export default class extends Controller {
7
- submit() {
8
- if (this.element.requestSubmit) {
9
- this.element.requestSubmit()
10
- } else {
11
- this.element.submit()
12
- }
13
- }
14
- }
@@ -1,27 +0,0 @@
1
- import { Controller } from "@hotwired/stimulus"
2
-
3
- // Opens a metric card's expanded view: the same series with room to read it.
4
- // A native <dialog> handles focus trapping, Escape, and inertness for us.
5
- export default class extends Controller {
6
- static targets = ["dialog"]
7
-
8
- open() {
9
- this.dialogTarget.showModal()
10
-
11
- // The chart inside was laid out while the dialog was closed, which means
12
- // it measured zero and drew nothing. Now that it has a size, tell it.
13
- this.dialogTarget.querySelectorAll(".railsui-chart").forEach((chart) => {
14
- chart.dispatchEvent(new CustomEvent("railsui-chart:refresh"))
15
- })
16
- }
17
-
18
- close() {
19
- this.dialogTarget.close()
20
- }
21
-
22
- // Clicking the backdrop lands on the dialog element itself; a click anywhere
23
- // inside lands on a child.
24
- closeOnBackdrop(event) {
25
- if (event.target === this.dialogTarget) this.dialogTarget.close()
26
- }
27
- }