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 +4 -4
- data/CHANGELOG.md +58 -1
- data/README.md +45 -8
- data/app/assets/javascripts/railsui_charts.js +486 -0
- data/app/assets/stylesheets/railsui_charts.css +35 -33
- data/app/javascript/controllers/railsui_chart_controller.js +132 -1
- data/app/javascript/railsui_charts/index.js +32 -0
- data/config/importmap.rb +12 -0
- data/lib/generators/railsui_charts/install/install_generator.rb +47 -32
- data/lib/railsui_charts/apex_options_builder.rb +10 -2
- data/lib/railsui_charts/chart_helper.rb +37 -9
- data/lib/railsui_charts/engine.rb +16 -0
- data/lib/railsui_charts/version.rb +1 -1
- data/package.json +37 -0
- metadata +5 -4
- data/lib/generators/railsui_charts/install/templates/railsui_chart_controller.js +0 -453
- data/lib/generators/railsui_charts/install/templates/railsui_chart_filters_controller.js +0 -14
- data/lib/generators/railsui_charts/install/templates/railsui_metric_dialog_controller.js +0 -27
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '049e45e39a745fa833e9ba7835a09ecf960cb837b5d86c07c48d311201cddd6e'
|
|
4
|
+
data.tar.gz: 5b649ab130a83f43fa87490699fc5514eb00ee9eb92d5bdf6d059e31ac61f73e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
+
};
|