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 +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +40 -5
- data/app/assets/javascripts/railsui_charts.js +388 -0
- 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/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: f3fa00b0555667e87a913c0341ac9efcff529d54d4eb3b7e6a70fbe12167d1a1
|
|
4
|
+
data.tar.gz: 47b6cc37a82c21c1ccbc1213a8876fa37fa8a2573ef6663629d4e1d09d4cb7fc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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.
|
|
@@ -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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
data/config/importmap.rb
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
say
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
].freeze
|
|
30
|
+
def print_next_steps
|
|
31
|
+
say ""
|
|
32
|
+
say "RailsUI Charts #{RailsuiCharts::VERSION} installed.", :green
|
|
33
|
+
say ""
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
46
|
+
say ' import { registerRailsuiCharts } from "@getrailsui/charts"', :cyan
|
|
47
|
+
say " registerRailsuiCharts(application)", :cyan
|
|
42
48
|
say ""
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
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.
|
|
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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
-
}
|