railsui_charts 0.2.1 → 0.2.3
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 +67 -1
- data/README.md +42 -2
- data/app/assets/javascripts/railsui_charts.js +63 -4
- data/app/assets/stylesheets/railsui_charts.css +128 -12
- data/app/javascript/controllers/railsui_chart_controller.js +34 -0
- data/app/javascript/controllers/railsui_metric_dialog_controller.js +45 -6
- data/lib/generators/railsui_charts/install/install_generator.rb +59 -13
- data/lib/railsui_charts/apex_options_builder.rb +105 -14
- data/lib/railsui_charts/chart_helper.rb +75 -4
- data/lib/railsui_charts/configuration.rb +7 -1
- data/lib/railsui_charts/metric_helper.rb +30 -8
- data/lib/railsui_charts/version.rb +1 -1
- data/package.json +4 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: edb2d338949929fa733699ed8c06cc91edd16c4e5bf9621db0e1db0fa019b245
|
|
4
|
+
data.tar.gz: dfb7e2e6dabeb591d544fdcf68070ea997451bd7f1fb19bb36a5f793a9160bbd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40dc738c972145c4b092a9802f77ea3a406b97305cf2c9355f209ac3bdd97612982fc6fea6c62b83e6ba20686fc67313b04bb1a18152685a68f11724df2bbe40
|
|
7
|
+
data.tar.gz: 04a089986c882997b1b297ed048495facf4a6da6761b9bafdfc4c5fb80a1d094cffe544fd30670469736d46c3ca497f9ffd6b3345a65663ff72e57e84a5a7bcb
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,69 @@ public API may change between minor versions.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.3]
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- The installer no longer writes a stylesheet import that cannot resolve. From
|
|
14
|
+
0.1.0 through 0.2.2 it appended `@import "../../stylesheets/railsui_charts";`
|
|
15
|
+
to `app/assets/tailwind/application.css`. Relative to that file the path names
|
|
16
|
+
`app/stylesheets` — not the application's own `app/assets/stylesheets`, and not
|
|
17
|
+
the gem's copy, which lives outside the application where no relative path can
|
|
18
|
+
reach it. Tailwind does not quietly skip an import it cannot resolve; it fails
|
|
19
|
+
the build. So an application that ran the installer and used Tailwind did not
|
|
20
|
+
get unstyled charts, it got no stylesheet build at all, and one that did not
|
|
21
|
+
use Tailwind never received the CSS by any route.
|
|
22
|
+
|
|
23
|
+
The stylesheet is now served from the gem through the asset pipeline, which
|
|
24
|
+
already carried it, and the installer links it in the application layout.
|
|
25
|
+
Nothing is copied, so `bundle update` moves the CSS the way it already moves
|
|
26
|
+
the Ruby and the JavaScript. Running `rails g railsui_charts:install` again
|
|
27
|
+
takes the old import back out.
|
|
28
|
+
|
|
29
|
+
- Chart data tables are hidden by the gem's own `.railsui-chart-data-table`
|
|
30
|
+
rather than by the host application's `sr-only`. An application not running
|
|
31
|
+
Tailwind has no such class, and rendered the table at full size in the middle
|
|
32
|
+
of the page. Even where it exists it does not hide a table: `width: 1px` is a
|
|
33
|
+
suggestion a table box ignores, because CSS never sizes one below its
|
|
34
|
+
min-content width. The table laid out at full width and, being positioned,
|
|
35
|
+
pushed the page's scroll width past the viewport — which on a phone reads as
|
|
36
|
+
the whole page sliding sideways. `table-layout: fixed` is what makes the width
|
|
37
|
+
stick. `sr-only` rides along for applications that already style it.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- `@getrailsui/charts/styles.css`, for an application that would rather pull the
|
|
42
|
+
stylesheet into its own Tailwind build than link it separately.
|
|
43
|
+
|
|
44
|
+
## [0.2.2]
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- `accessible_table:` on `railsui_chart`, for charts whose series are not the
|
|
49
|
+
numbers a reader wants. A caller passes `{ headers:, rows: }` and it replaces
|
|
50
|
+
the table derived from the series. The CSV export in Rails UI Charts Pro is
|
|
51
|
+
built from that table, so it is corrected by the same option. This is what
|
|
52
|
+
lets a waterfall describe itself as movement rather than as the invisible
|
|
53
|
+
plinth it stacks to get the shape drawn.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- Series that do not share an x-axis now align to the union of their labels, or
|
|
58
|
+
to an explicit `categories:` when one is given, since series order is not
|
|
59
|
+
always reading order. Categories were read off the first series alone and
|
|
60
|
+
every later series was flattened to bare values from position zero, so a
|
|
61
|
+
projection labelled November landed on January, on top of the history it was
|
|
62
|
+
meant to continue. Apex draws the resulting gaps as gaps.
|
|
63
|
+
- A complex combo keeps a `categories:` it was given. Derived lists are still
|
|
64
|
+
stripped, because one alongside numeric pair data makes Apex plot nothing, but
|
|
65
|
+
Apex reads `xaxis.categories` ahead of everything else and without one sets the
|
|
66
|
+
axis from the first series alone. For a forecast that was ruinous: the band is
|
|
67
|
+
drawn first so it sits behind the lines, so the projection's months became the
|
|
68
|
+
whole axis and the earlier history disappeared.
|
|
69
|
+
|
|
70
|
+
## [0.2.1]
|
|
71
|
+
|
|
9
72
|
### Fixed
|
|
10
73
|
|
|
11
74
|
- Sparkline charts now use the Rails UI tooltip renderer, so hovered points show
|
|
@@ -123,7 +186,10 @@ First release.
|
|
|
123
186
|
reachable only by hovering a mark
|
|
124
187
|
- Animation stops when the reader has asked for reduced motion
|
|
125
188
|
|
|
126
|
-
[Unreleased]: https://github.com/getrailsui/railsui_charts/compare/v0.2.
|
|
189
|
+
[Unreleased]: https://github.com/getrailsui/railsui_charts/compare/v0.2.3...HEAD
|
|
190
|
+
[0.2.3]: https://github.com/getrailsui/railsui_charts/compare/v0.2.2...v0.2.3
|
|
191
|
+
[0.2.2]: https://github.com/getrailsui/railsui_charts/compare/v0.2.1...v0.2.2
|
|
192
|
+
[0.2.1]: https://github.com/getrailsui/railsui_charts/compare/v0.2.0...v0.2.1
|
|
127
193
|
[0.2.0]: https://github.com/getrailsui/railsui_charts/compare/v0.1.2...v0.2.0
|
|
128
194
|
[0.1.2]: https://github.com/getrailsui/railsui_charts/compare/v0.1.1...v0.1.2
|
|
129
195
|
[0.1.1]: https://github.com/getrailsui/railsui_charts/compare/v0.1.0...v0.1.1
|
data/README.md
CHANGED
|
@@ -42,8 +42,31 @@ bundle install
|
|
|
42
42
|
rails g railsui_charts:install
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
The generator
|
|
46
|
-
rather than copied into your app, so
|
|
45
|
+
The generator links the stylesheet in your layout. Both the CSS and the
|
|
46
|
+
JavaScript are served from the gem rather than copied into your app, so
|
|
47
|
+
`bundle update` moves all of it.
|
|
48
|
+
|
|
49
|
+
### Stylesheet
|
|
50
|
+
|
|
51
|
+
The generator adds this to your layout's `<head>`:
|
|
52
|
+
|
|
53
|
+
```erb
|
|
54
|
+
<%= stylesheet_link_tag "railsui_charts" %>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The engine puts the file on the asset path and precompiles it, so this works on
|
|
58
|
+
Propshaft and on Sprockets without anything further.
|
|
59
|
+
|
|
60
|
+
If you would rather the CSS went through your own Tailwind build — to control
|
|
61
|
+
where it lands in the cascade, say — install the npm package below and import it
|
|
62
|
+
instead of linking it:
|
|
63
|
+
|
|
64
|
+
```css
|
|
65
|
+
/* app/assets/tailwind/application.css */
|
|
66
|
+
@import "@getrailsui/charts/styles.css";
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Do one or the other, not both.
|
|
47
70
|
|
|
48
71
|
### Bundled apps (esbuild, bun, rollup, webpack)
|
|
49
72
|
|
|
@@ -75,6 +98,23 @@ import { registerRailsuiCharts } from "@getrailsui/charts"
|
|
|
75
98
|
registerRailsuiCharts(application)
|
|
76
99
|
```
|
|
77
100
|
|
|
101
|
+
### Upgrading from 0.2.2 and earlier
|
|
102
|
+
|
|
103
|
+
Those versions' installer wrote an `@import` into `app/assets/tailwind/application.css`
|
|
104
|
+
that could never resolve — it named `app/stylesheets`, which is neither your app's
|
|
105
|
+
stylesheets nor the gem's. Tailwind fails the build on an import it cannot find, so
|
|
106
|
+
if you have this line, your CSS build is broken:
|
|
107
|
+
|
|
108
|
+
```css
|
|
109
|
+
@import "../../stylesheets/railsui_charts";
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Running the installer again removes it and links the stylesheet properly:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
rails g railsui_charts:install
|
|
116
|
+
```
|
|
117
|
+
|
|
78
118
|
### Upgrading from 0.1.x
|
|
79
119
|
|
|
80
120
|
0.1.x copied the controllers into `app/javascript/controllers`. Those copies are
|
|
@@ -141,6 +141,9 @@ var railsui_chart_controller_default = class extends Controller {
|
|
|
141
141
|
const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {};
|
|
142
142
|
const point = this.configuredPoint(options, w, seriesIndex, dataPointIndex) || {};
|
|
143
143
|
const title = point.name || point.label || point.x || configuredSeries.name;
|
|
144
|
+
if (Array.isArray(point.y)) {
|
|
145
|
+
return this.arrayPointTooltipMarkup(point, configuredSeries, options, w, seriesIndex, dataPointIndex, format);
|
|
146
|
+
}
|
|
144
147
|
const row = {
|
|
145
148
|
label: configuredSeries.name || "Value",
|
|
146
149
|
value: point.y ?? series?.[seriesIndex]?.[dataPointIndex],
|
|
@@ -149,6 +152,31 @@ var railsui_chart_controller_default = class extends Controller {
|
|
|
149
152
|
};
|
|
150
153
|
return this.tooltipMarkup(title, [row], null, format);
|
|
151
154
|
}
|
|
155
|
+
arrayPointTooltipMarkup(point, configuredSeries, options, w, seriesIndex, dataPointIndex, format) {
|
|
156
|
+
const labels = options.tooltip_value_labels || this.defaultArrayValueLabels(options.chart?.type, point.y.length);
|
|
157
|
+
const color = this.pointColor(options, w, seriesIndex, dataPointIndex);
|
|
158
|
+
const rows = point.y.map((value, index) => ({
|
|
159
|
+
label: labels[index] || `Value ${index + 1}`,
|
|
160
|
+
value,
|
|
161
|
+
color: options.tooltip_value_colors?.[index] || color,
|
|
162
|
+
format: null
|
|
163
|
+
}));
|
|
164
|
+
if (point.v !== void 0 && point.v !== null) {
|
|
165
|
+
rows.push({
|
|
166
|
+
label: options.tooltip_volume_label || "Volume",
|
|
167
|
+
value: point.v,
|
|
168
|
+
color: options.tooltip_volume_color || color,
|
|
169
|
+
format: this.formatterFor(options.volume_format || "human", options.currency)
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return this.tooltipMarkup(point.name || point.label || point.x || configuredSeries.name, rows, null, format);
|
|
173
|
+
}
|
|
174
|
+
defaultArrayValueLabels(type, length) {
|
|
175
|
+
if (type === "candlestick" && length === 4) return ["Open", "High", "Low", "Close"];
|
|
176
|
+
if (type === "boxPlot" && length === 5) return ["Min", "Q1", "Median", "Q3", "Max"];
|
|
177
|
+
if (type === "rangeArea" && length === 2) return ["Low", "High"];
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
152
180
|
rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
|
|
153
181
|
const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {};
|
|
154
182
|
const point = configuredSeries.data?.[dataPointIndex] || {};
|
|
@@ -448,18 +476,49 @@ import { Controller as Controller3 } from "@hotwired/stimulus";
|
|
|
448
476
|
var railsui_metric_dialog_controller_default = class extends Controller3 {
|
|
449
477
|
static targets = ["dialog"];
|
|
450
478
|
open() {
|
|
479
|
+
this.dialogTarget.classList.remove("railsui-metric-dialog--closing");
|
|
451
480
|
this.dialogTarget.showModal();
|
|
452
|
-
|
|
453
|
-
|
|
481
|
+
requestAnimationFrame(() => {
|
|
482
|
+
this.dialogTarget.classList.add("railsui-metric-dialog--open");
|
|
483
|
+
this.refreshCharts();
|
|
454
484
|
});
|
|
455
485
|
}
|
|
456
486
|
close() {
|
|
457
|
-
this.
|
|
487
|
+
this.closeDialog();
|
|
488
|
+
}
|
|
489
|
+
cancel(event) {
|
|
490
|
+
event.preventDefault();
|
|
491
|
+
this.closeDialog();
|
|
458
492
|
}
|
|
459
493
|
// Clicking the backdrop lands on the dialog element itself; a click anywhere
|
|
460
494
|
// inside lands on a child.
|
|
461
495
|
closeOnBackdrop(event) {
|
|
462
|
-
if (event.target === this.dialogTarget) this.
|
|
496
|
+
if (event.target === this.dialogTarget) this.closeDialog();
|
|
497
|
+
}
|
|
498
|
+
refreshCharts() {
|
|
499
|
+
this.dialogTarget.querySelectorAll(".railsui-chart").forEach((chart) => {
|
|
500
|
+
chart.dispatchEvent(new CustomEvent("railsui-chart:refresh"));
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
closeDialog() {
|
|
504
|
+
const dialog = this.dialogTarget;
|
|
505
|
+
if (!dialog.open) return;
|
|
506
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
507
|
+
dialog.classList.remove("railsui-metric-dialog--open", "railsui-metric-dialog--closing");
|
|
508
|
+
dialog.close();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
dialog.classList.remove("railsui-metric-dialog--open");
|
|
512
|
+
dialog.classList.add("railsui-metric-dialog--closing");
|
|
513
|
+
let closed = false;
|
|
514
|
+
const finish = () => {
|
|
515
|
+
if (closed) return;
|
|
516
|
+
closed = true;
|
|
517
|
+
dialog.classList.remove("railsui-metric-dialog--closing");
|
|
518
|
+
dialog.close();
|
|
519
|
+
};
|
|
520
|
+
dialog.addEventListener("transitionend", finish, { once: true });
|
|
521
|
+
setTimeout(finish, 240);
|
|
463
522
|
}
|
|
464
523
|
};
|
|
465
524
|
|
|
@@ -110,6 +110,51 @@
|
|
|
110
110
|
width: 100%;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/* ------------------------------------------------------------- chart data */
|
|
114
|
+
|
|
115
|
+
/* The table behind every chart: out of sight, still in the accessibility tree,
|
|
116
|
+
so no value is reachable only by hovering a mark.
|
|
117
|
+
*
|
|
118
|
+
* Hidden here rather than by borrowing the host's `sr-only`, for two reasons.
|
|
119
|
+
* An app that is not running Tailwind has no such class, and the table renders
|
|
120
|
+
* at full size in the middle of the page. And even where it exists, `sr-only`
|
|
121
|
+
* does not hide a table: it shrinks an element to 1px and clips the rest, but
|
|
122
|
+
* width is only a suggestion to a table box, which CSS never sizes below its
|
|
123
|
+
* min-content width. The hidden table lays out at its full width, and since it
|
|
124
|
+
* is positioned, it pushes the page's scroll width past the viewport — which
|
|
125
|
+
* on a phone reads as the whole page sliding sideways.
|
|
126
|
+
*
|
|
127
|
+
* Fixed layout is what makes the 1px stick. The caption needs the same
|
|
128
|
+
* treatment for the same reason: left in flow, its own min-content width holds
|
|
129
|
+
* the table open on its own. Neither is removed from the accessibility tree.
|
|
130
|
+
*
|
|
131
|
+
* The height stays at the table's own — that one is a minimum a table will not
|
|
132
|
+
* go under either, and `contain` does not apply to a table box. It costs
|
|
133
|
+
* nothing: the element is out of flow, and a page carrying a chart is taller
|
|
134
|
+
* than the table under it. Collapsing it would take `display: block`, which
|
|
135
|
+
* throws away the table semantics this element exists for. */
|
|
136
|
+
.railsui-chart-data-table {
|
|
137
|
+
position: absolute;
|
|
138
|
+
width: 1px;
|
|
139
|
+
height: 1px;
|
|
140
|
+
padding: 0;
|
|
141
|
+
margin: -1px;
|
|
142
|
+
border: 0;
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
white-space: nowrap;
|
|
145
|
+
clip-path: inset(50%);
|
|
146
|
+
table-layout: fixed;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.railsui-chart-data-table > caption {
|
|
150
|
+
position: absolute;
|
|
151
|
+
width: 1px;
|
|
152
|
+
height: 1px;
|
|
153
|
+
overflow: hidden;
|
|
154
|
+
white-space: nowrap;
|
|
155
|
+
clip-path: inset(50%);
|
|
156
|
+
}
|
|
157
|
+
|
|
113
158
|
/* -------------------------------------------------------- small multiples */
|
|
114
159
|
|
|
115
160
|
.railsui-small-multiples {
|
|
@@ -518,6 +563,12 @@
|
|
|
518
563
|
margin-top: 0.5rem;
|
|
519
564
|
}
|
|
520
565
|
|
|
566
|
+
.railsui-metric-card__chart .railsui-chart,
|
|
567
|
+
.railsui-metric-card__chart .apexcharts-canvas,
|
|
568
|
+
.railsui-metric-card__chart .apexcharts-svg {
|
|
569
|
+
overflow: visible !important;
|
|
570
|
+
}
|
|
571
|
+
|
|
521
572
|
/* Rendered outside the plot so the first and last labels sit flush with the
|
|
522
573
|
card edges. Apex centres them on their data point and clips at the canvas
|
|
523
574
|
edge, which costs either the label's first characters or plot width. */
|
|
@@ -568,22 +619,41 @@
|
|
|
568
619
|
/* --------------------------------------------------------- expanded view */
|
|
569
620
|
|
|
570
621
|
.railsui-metric-dialog {
|
|
571
|
-
width: min(
|
|
572
|
-
max-height: calc(100vh -
|
|
622
|
+
width: min(64rem, calc(100vw - 2rem));
|
|
623
|
+
max-height: calc(100vh - 2rem);
|
|
573
624
|
padding: 0;
|
|
574
625
|
border: 1px solid var(--rui-chart-border);
|
|
575
626
|
border-radius: 0.75rem;
|
|
576
627
|
background-color: var(--rui-chart-surface);
|
|
577
628
|
color: var(--rui-chart-metric-value);
|
|
578
|
-
|
|
629
|
+
opacity: 0;
|
|
630
|
+
overflow: auto;
|
|
631
|
+
transform: translateY(0.5rem) scale(0.98);
|
|
632
|
+
transition: opacity 160ms ease, transform 180ms ease;
|
|
579
633
|
}
|
|
580
634
|
|
|
581
635
|
.railsui-metric-dialog::backdrop {
|
|
582
|
-
background-color:
|
|
636
|
+
background-color: transparent;
|
|
637
|
+
transition: background-color 160ms ease;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.railsui-metric-dialog--open {
|
|
641
|
+
opacity: 1;
|
|
642
|
+
transform: translateY(0) scale(1);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.railsui-metric-dialog--open::backdrop {
|
|
646
|
+
background-color: rgba(15, 23, 42, 0.48);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
.railsui-metric-dialog--closing {
|
|
650
|
+
opacity: 0;
|
|
651
|
+
transform: translateY(0.25rem) scale(0.99);
|
|
583
652
|
}
|
|
584
653
|
|
|
585
654
|
.railsui-metric-dialog__panel {
|
|
586
|
-
|
|
655
|
+
min-width: 0;
|
|
656
|
+
padding: 1.25rem 1.75rem 1.5rem;
|
|
587
657
|
}
|
|
588
658
|
|
|
589
659
|
.railsui-metric-dialog__head {
|
|
@@ -595,21 +665,67 @@
|
|
|
595
665
|
}
|
|
596
666
|
|
|
597
667
|
.railsui-metric-dialog__close {
|
|
668
|
+
display: inline-flex;
|
|
669
|
+
align-items: center;
|
|
670
|
+
justify-content: center;
|
|
598
671
|
flex-shrink: 0;
|
|
599
|
-
width:
|
|
600
|
-
height:
|
|
672
|
+
width: 2.5rem;
|
|
673
|
+
height: 2.5rem;
|
|
601
674
|
border: 0;
|
|
602
|
-
border-radius:
|
|
675
|
+
border-radius: 0.375rem;
|
|
603
676
|
background-color: transparent;
|
|
604
677
|
color: var(--rui-chart-metric-muted);
|
|
605
|
-
font-size: 1.25rem;
|
|
606
|
-
line-height: 1;
|
|
607
678
|
cursor: pointer;
|
|
608
|
-
transition: background-color 120ms ease;
|
|
679
|
+
transition: background-color 120ms ease, color 120ms ease;
|
|
609
680
|
}
|
|
610
681
|
|
|
611
|
-
.railsui-metric-dialog__close:hover
|
|
682
|
+
.railsui-metric-dialog__close:hover,
|
|
683
|
+
.railsui-metric-dialog__close:focus-visible {
|
|
612
684
|
background-color: var(--rui-chart-hover);
|
|
685
|
+
color: var(--rui-chart-metric-value);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
.railsui-metric-dialog__close:focus-visible {
|
|
689
|
+
outline: 2px solid var(--rui-chart-primary);
|
|
690
|
+
outline-offset: 2px;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.railsui-metric-dialog__close-icon {
|
|
694
|
+
width: 1.5rem;
|
|
695
|
+
height: 1.5rem;
|
|
696
|
+
pointer-events: none;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
.railsui-metric-dialog__chart,
|
|
700
|
+
.railsui-metric-dialog__chart .railsui-chart,
|
|
701
|
+
.railsui-metric-dialog__chart .apexcharts-canvas,
|
|
702
|
+
.railsui-metric-dialog__chart .apexcharts-svg {
|
|
703
|
+
overflow: visible !important;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
@media (max-width: 640px) {
|
|
707
|
+
.railsui-metric-dialog {
|
|
708
|
+
width: calc(100vw - 1rem);
|
|
709
|
+
max-height: calc(100dvh - 1rem);
|
|
710
|
+
border-radius: 0.625rem;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
.railsui-metric-dialog__panel {
|
|
714
|
+
padding: 1rem;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
.railsui-metric-dialog__head {
|
|
718
|
+
gap: 0.75rem;
|
|
719
|
+
margin-bottom: 0.75rem;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
@media (prefers-reduced-motion: reduce) {
|
|
724
|
+
.railsui-metric-dialog,
|
|
725
|
+
.railsui-metric-dialog::backdrop,
|
|
726
|
+
.railsui-metric-dialog__close {
|
|
727
|
+
transition: none;
|
|
728
|
+
}
|
|
613
729
|
}
|
|
614
730
|
|
|
615
731
|
/* ---------------------------------------------------------------- deltas */
|
|
@@ -193,6 +193,10 @@ export default class extends Controller {
|
|
|
193
193
|
const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {}
|
|
194
194
|
const point = this.configuredPoint(options, w, seriesIndex, dataPointIndex) || {}
|
|
195
195
|
const title = point.name || point.label || point.x || configuredSeries.name
|
|
196
|
+
if (Array.isArray(point.y)) {
|
|
197
|
+
return this.arrayPointTooltipMarkup(point, configuredSeries, options, w, seriesIndex, dataPointIndex, format)
|
|
198
|
+
}
|
|
199
|
+
|
|
196
200
|
const row = {
|
|
197
201
|
label: configuredSeries.name || "Value",
|
|
198
202
|
value: point.y ?? series?.[seriesIndex]?.[dataPointIndex],
|
|
@@ -203,6 +207,36 @@ export default class extends Controller {
|
|
|
203
207
|
return this.tooltipMarkup(title, [row], null, format)
|
|
204
208
|
}
|
|
205
209
|
|
|
210
|
+
arrayPointTooltipMarkup(point, configuredSeries, options, w, seriesIndex, dataPointIndex, format) {
|
|
211
|
+
const labels = options.tooltip_value_labels || this.defaultArrayValueLabels(options.chart?.type, point.y.length)
|
|
212
|
+
const color = this.pointColor(options, w, seriesIndex, dataPointIndex)
|
|
213
|
+
const rows = point.y.map((value, index) => ({
|
|
214
|
+
label: labels[index] || `Value ${index + 1}`,
|
|
215
|
+
value,
|
|
216
|
+
color: options.tooltip_value_colors?.[index] || color,
|
|
217
|
+
format: null
|
|
218
|
+
}))
|
|
219
|
+
|
|
220
|
+
if (point.v !== undefined && point.v !== null) {
|
|
221
|
+
rows.push({
|
|
222
|
+
label: options.tooltip_volume_label || "Volume",
|
|
223
|
+
value: point.v,
|
|
224
|
+
color: options.tooltip_volume_color || color,
|
|
225
|
+
format: this.formatterFor(options.volume_format || "human", options.currency)
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return this.tooltipMarkup(point.name || point.label || point.x || configuredSeries.name, rows, null, format)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
defaultArrayValueLabels(type, length) {
|
|
233
|
+
if (type === "candlestick" && length === 4) return ["Open", "High", "Low", "Close"]
|
|
234
|
+
if (type === "boxPlot" && length === 5) return ["Min", "Q1", "Median", "Q3", "Max"]
|
|
235
|
+
if (type === "rangeArea" && length === 2) return ["Low", "High"]
|
|
236
|
+
|
|
237
|
+
return []
|
|
238
|
+
}
|
|
239
|
+
|
|
206
240
|
rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
|
|
207
241
|
const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {}
|
|
208
242
|
const point = configuredSeries.data?.[dataPointIndex] || {}
|
|
@@ -6,22 +6,61 @@ export default class extends Controller {
|
|
|
6
6
|
static targets = ["dialog"]
|
|
7
7
|
|
|
8
8
|
open() {
|
|
9
|
+
this.dialogTarget.classList.remove("railsui-metric-dialog--closing")
|
|
9
10
|
this.dialogTarget.showModal()
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
chart.dispatchEvent(new CustomEvent("railsui-chart:refresh"))
|
|
12
|
+
requestAnimationFrame(() => {
|
|
13
|
+
this.dialogTarget.classList.add("railsui-metric-dialog--open")
|
|
14
|
+
this.refreshCharts()
|
|
15
15
|
})
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
close() {
|
|
19
|
-
this.
|
|
19
|
+
this.closeDialog()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
cancel(event) {
|
|
23
|
+
event.preventDefault()
|
|
24
|
+
this.closeDialog()
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
// Clicking the backdrop lands on the dialog element itself; a click anywhere
|
|
23
28
|
// inside lands on a child.
|
|
24
29
|
closeOnBackdrop(event) {
|
|
25
|
-
if (event.target === this.dialogTarget) this.
|
|
30
|
+
if (event.target === this.dialogTarget) this.closeDialog()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
refreshCharts() {
|
|
34
|
+
// The chart inside was laid out while the dialog was closed, which means
|
|
35
|
+
// it measured zero and drew nothing. Now that it has a size, tell it.
|
|
36
|
+
this.dialogTarget.querySelectorAll(".railsui-chart").forEach((chart) => {
|
|
37
|
+
chart.dispatchEvent(new CustomEvent("railsui-chart:refresh"))
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
closeDialog() {
|
|
42
|
+
const dialog = this.dialogTarget
|
|
43
|
+
if (!dialog.open) return
|
|
44
|
+
|
|
45
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
46
|
+
dialog.classList.remove("railsui-metric-dialog--open", "railsui-metric-dialog--closing")
|
|
47
|
+
dialog.close()
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
dialog.classList.remove("railsui-metric-dialog--open")
|
|
52
|
+
dialog.classList.add("railsui-metric-dialog--closing")
|
|
53
|
+
|
|
54
|
+
let closed = false
|
|
55
|
+
const finish = () => {
|
|
56
|
+
if (closed) return
|
|
57
|
+
|
|
58
|
+
closed = true
|
|
59
|
+
dialog.classList.remove("railsui-metric-dialog--closing")
|
|
60
|
+
dialog.close()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
dialog.addEventListener("transitionend", finish, { once: true })
|
|
64
|
+
setTimeout(finish, 240)
|
|
26
65
|
}
|
|
27
66
|
}
|
|
@@ -8,23 +8,62 @@ module RailsuiCharts
|
|
|
8
8
|
# version was installed — a fix could ship and reach nobody. Bundled apps
|
|
9
9
|
# take it from npm, importmap apps from the pin this engine adds, and both
|
|
10
10
|
# follow the gem from then on.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
#
|
|
12
|
+
# The stylesheet now follows the same rule, for the same reason and after
|
|
13
|
+
# the same mistake. See remove_broken_css_import.
|
|
14
|
+
|
|
15
|
+
LAYOUT = "app/views/layouts/application.html.erb"
|
|
16
|
+
TAILWIND_ENTRY = "app/assets/tailwind/application.css"
|
|
17
|
+
STYLESHEET_TAG = %q(<%= stylesheet_link_tag "railsui_charts" %>).freeze
|
|
18
|
+
|
|
19
|
+
# What 0.1.0 through 0.2.2 wrote into the Tailwind entry. It never worked.
|
|
20
|
+
# Relative to app/assets/tailwind it names app/stylesheets — not the
|
|
21
|
+
# application's own app/assets/stylesheets, and certainly not the file it
|
|
22
|
+
# was after, which lives inside the gem where no relative path from the
|
|
23
|
+
# application can reach it. Tailwind does not quietly skip an import it
|
|
24
|
+
# cannot resolve; it fails the build. So this is removed rather than
|
|
25
|
+
# corrected, from anyone who ran the old generator.
|
|
26
|
+
BROKEN_IMPORT = %r{^@import\s+"\.\./\.\./stylesheets/railsui_charts";?[ \t]*\r?\n}.freeze
|
|
27
|
+
|
|
28
|
+
def remove_broken_css_import
|
|
29
|
+
path = File.join(destination_root, TAILWIND_ENTRY)
|
|
30
|
+
return unless File.exist?(path)
|
|
31
|
+
|
|
32
|
+
contents = File.read(path)
|
|
33
|
+
return unless contents.match?(BROKEN_IMPORT)
|
|
34
|
+
|
|
35
|
+
File.write(path, contents.sub(BROKEN_IMPORT, ""))
|
|
36
|
+
say "✓ Removed the unresolvable @import from #{TAILWIND_ENTRY}", :green
|
|
37
|
+
say " It named a path that does not exist and failed the Tailwind build.", :yellow
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Served from the gem through the asset pipeline, which already carries it:
|
|
41
|
+
# the engine puts app/assets/stylesheets on the load path and precompiles
|
|
42
|
+
# the file. Nothing is copied, so `bundle update` moves the CSS too.
|
|
43
|
+
def add_stylesheet_link
|
|
44
|
+
path = File.join(destination_root, LAYOUT)
|
|
45
|
+
|
|
46
|
+
unless File.exist?(path)
|
|
47
|
+
say "⚠️ #{LAYOUT} not found. Add this to your layout's <head>:", :yellow
|
|
48
|
+
say " #{STYLESHEET_TAG}"
|
|
19
49
|
return
|
|
20
50
|
end
|
|
21
51
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
52
|
+
contents = File.read(path)
|
|
53
|
+
|
|
54
|
+
if contents.include?(%("railsui_charts"))
|
|
55
|
+
say "✓ Rails UI Charts stylesheet already linked", :green
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
unless contents.match?(%r{</head>})
|
|
60
|
+
say "⚠️ No </head> found in #{LAYOUT}. Add this to your layout's <head>:", :yellow
|
|
61
|
+
say " #{STYLESHEET_TAG}"
|
|
62
|
+
return
|
|
27
63
|
end
|
|
64
|
+
|
|
65
|
+
inject_into_file LAYOUT, " #{STYLESHEET_TAG}\n", before: %r{^[ \t]*</head>}
|
|
66
|
+
say "✓ Linked the Rails UI Charts stylesheet in #{LAYOUT}", :green
|
|
28
67
|
end
|
|
29
68
|
|
|
30
69
|
def print_next_steps
|
|
@@ -51,6 +90,13 @@ module RailsuiCharts
|
|
|
51
90
|
say "ApexCharts is a peer dependency; pin it too:", :cyan
|
|
52
91
|
say ' pin "apexcharts", to: "https://esm.sh/apexcharts@3.45.2"', :cyan
|
|
53
92
|
say ""
|
|
93
|
+
elsif File.exist?(File.join(destination_root, TAILWIND_ENTRY))
|
|
94
|
+
say "Prefer the CSS inside your Tailwind build? Once the package is", :cyan
|
|
95
|
+
say "installed you can drop the stylesheet_link_tag and import it:", :cyan
|
|
96
|
+
say ""
|
|
97
|
+
say " /* #{TAILWIND_ENTRY} */", :cyan
|
|
98
|
+
say ' @import "@getrailsui/charts/styles.css";', :cyan
|
|
99
|
+
say ""
|
|
54
100
|
end
|
|
55
101
|
|
|
56
102
|
say "Then use <%= railsui_chart data, type: :area %> in your views.", :cyan
|
|
@@ -132,6 +132,12 @@ module RailsuiCharts
|
|
|
132
132
|
@series.any? { |series| series[:type].present? }
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
def complex_combo?
|
|
136
|
+
combo? && @series.each_with_index.any? do |series, index|
|
|
137
|
+
RailsuiCharts.config.complex_point_types.include?(series_point_type(series, index))
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
135
141
|
def dual_axis?
|
|
136
142
|
@series.any? { |series| series[:axis] == :right }
|
|
137
143
|
end
|
|
@@ -155,6 +161,9 @@ module RailsuiCharts
|
|
|
155
161
|
normalized = { x: point[:x], y: span_or_value(point), z: point[:z] }
|
|
156
162
|
normalized[:meta] = point[:meta] if point.key?(:meta)
|
|
157
163
|
normalized[:name] = point[:name] if point.key?(:name)
|
|
164
|
+
normalized[:label] = point[:label] if point.key?(:label)
|
|
165
|
+
normalized[:v] = point[:v] if point.key?(:v)
|
|
166
|
+
normalized[:points] = point[:points] if point.key?(:points)
|
|
158
167
|
normalized
|
|
159
168
|
when Array
|
|
160
169
|
{ x: point[0], y: point[1], z: point[2] }
|
|
@@ -174,9 +183,49 @@ module RailsuiCharts
|
|
|
174
183
|
end
|
|
175
184
|
|
|
176
185
|
def categories
|
|
186
|
+
return category_union if misaligned_series?
|
|
187
|
+
|
|
177
188
|
@data.map { |d| d[:x] }.compact
|
|
178
189
|
end
|
|
179
190
|
|
|
191
|
+
# The union of every series' labels, in the order they first appear.
|
|
192
|
+
#
|
|
193
|
+
# Categories used to be read off the first series alone, and every later
|
|
194
|
+
# series was then flattened to bare values and drawn from position zero.
|
|
195
|
+
# For series that share an x-axis — the ordinary case — that is the same
|
|
196
|
+
# thing. For series that do not, it silently plotted them in the wrong
|
|
197
|
+
# place: a projection labelled November landed on January, on top of the
|
|
198
|
+
# history it was meant to continue.
|
|
199
|
+
def category_union
|
|
200
|
+
# A caller who states the axis gets it. Series order decides first
|
|
201
|
+
# appearance otherwise, and that is not always the reading order: a
|
|
202
|
+
# forecast draws its band first so it sits behind the lines, which would
|
|
203
|
+
# otherwise put next November before last January.
|
|
204
|
+
@category_union ||= Array(@options[:categories]).presence ||
|
|
205
|
+
@series.flat_map { |series| series_labels(series) }.uniq
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Only when the series genuinely disagree. A series of bare values carries
|
|
209
|
+
# no labels to align on, and aligning identical label lists would be the
|
|
210
|
+
# same list — so in both cases nothing changes.
|
|
211
|
+
def misaligned_series?
|
|
212
|
+
return @misaligned unless @misaligned.nil?
|
|
213
|
+
|
|
214
|
+
@misaligned =
|
|
215
|
+
if @series.length < 2
|
|
216
|
+
false
|
|
217
|
+
elsif @options[:categories].present?
|
|
218
|
+
@series.all? { |series| series_labels(series).any? }
|
|
219
|
+
else
|
|
220
|
+
labels = @series.map { |series| series_labels(series) }
|
|
221
|
+
labels.none?(&:empty?) && labels.uniq.length > 1
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def series_labels(series)
|
|
226
|
+
Array(series[:data]).filter_map { |point| point[:x] if point.is_a?(Hash) }
|
|
227
|
+
end
|
|
228
|
+
|
|
180
229
|
# A bare array of values carries no labels, and Apex sizes the x-axis from
|
|
181
230
|
# this list: given an empty one it draws nothing at all — no canvas, no
|
|
182
231
|
# error, just an element that stays empty. Positions stand in, which is
|
|
@@ -537,18 +586,23 @@ module RailsuiCharts
|
|
|
537
586
|
return series_values if circular?
|
|
538
587
|
|
|
539
588
|
plotted = @series.each_with_index.map do |series, index|
|
|
540
|
-
|
|
589
|
+
point_type = series_point_type(series, index)
|
|
590
|
+
entry = { name: series[:name] || (index.zero? ? series_label : "Series #{index + 1}"), data: points_for(series[:data], point_type) }
|
|
541
591
|
# Only on a combo. Naming a type on every series of an ordinary chart
|
|
542
592
|
# would override the chart-level one and quietly ignore `type:`.
|
|
543
|
-
entry[:type] =
|
|
593
|
+
entry[:type] = apex_type_for(point_type) if combo?
|
|
544
594
|
entry
|
|
545
595
|
end
|
|
546
596
|
|
|
547
597
|
comparing? ? plotted + [{ name: compare_label, data: @compare.map { |point| point[:y] } }] : plotted
|
|
548
598
|
end
|
|
549
599
|
|
|
550
|
-
def
|
|
551
|
-
|
|
600
|
+
def series_point_type(series, index)
|
|
601
|
+
(series[:type] || (index.zero? ? @type : @series.first[:type]) || @type).to_sym
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def points_for(points, point_type = @type)
|
|
605
|
+
case point_type
|
|
552
606
|
when :scatter then points.map { |point| [point[:x], point[:y]] }
|
|
553
607
|
when :bubble then points.map { |point| { x: point[:x], y: point[:y], z: point[:z] || 1 } }
|
|
554
608
|
when :range_bar
|
|
@@ -563,14 +617,35 @@ module RailsuiCharts
|
|
|
563
617
|
else
|
|
564
618
|
# A registered type may draw its own labels rather than read them off an
|
|
565
619
|
# axis, in which case flattening the point to a value loses them.
|
|
566
|
-
return points.map { |point|
|
|
620
|
+
return points.map { |point| preserved_point(point) } if preserved_points?(point_type) || preserve_combo_points?(points)
|
|
621
|
+
return aligned_values(points) if misaligned_series?
|
|
567
622
|
|
|
568
623
|
points.map { |point| point[:y] }
|
|
569
624
|
end
|
|
570
625
|
end
|
|
571
626
|
|
|
572
|
-
|
|
573
|
-
|
|
627
|
+
# A slot per shared category, nil where this series has nothing. Apex draws
|
|
628
|
+
# a nil as a gap, which is what a forecast that begins after the history
|
|
629
|
+
# ends should look like.
|
|
630
|
+
def aligned_values(points)
|
|
631
|
+
lookup = points.each_with_object({}) do |point, memo|
|
|
632
|
+
memo[point[:x]] = point[:y] if point.is_a?(Hash)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
category_union.map { |category| lookup[category] }
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def preserve_combo_points?(points)
|
|
639
|
+
complex_combo? && points.any? { |point| point[:x].present? }
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def preserved_points?(type)
|
|
643
|
+
RailsuiCharts.config.labelled_point_types.include?(type) ||
|
|
644
|
+
RailsuiCharts.config.complex_point_types.include?(type)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def preserved_point(point)
|
|
648
|
+
point.slice(:x, :y, :z, :meta, :name, :label, :v, :points).compact
|
|
574
649
|
end
|
|
575
650
|
|
|
576
651
|
# Apex plots a range against a time axis in milliseconds. Handing it a Time
|
|
@@ -599,7 +674,7 @@ module RailsuiCharts
|
|
|
599
674
|
return { labels: { show: false } } if circular?
|
|
600
675
|
return numeric_axis(:x) if numeric_xaxis?
|
|
601
676
|
|
|
602
|
-
{
|
|
677
|
+
options = {
|
|
603
678
|
categories: axis_categories,
|
|
604
679
|
labels: {
|
|
605
680
|
show: categories.any? && !sparkline?,
|
|
@@ -617,6 +692,14 @@ module RailsuiCharts
|
|
|
617
692
|
tooltip: { enabled: false },
|
|
618
693
|
type: "category"
|
|
619
694
|
}
|
|
695
|
+
|
|
696
|
+
# A complex combo carries its own x in every point, and a derived category
|
|
697
|
+
# list alongside numeric pair data makes Apex plot nothing. An explicit
|
|
698
|
+
# one is different: Apex reads xaxis.categories ahead of everything else,
|
|
699
|
+
# and without it a combo takes its whole axis from the first series alone
|
|
700
|
+
# — which draws every other series from position zero.
|
|
701
|
+
options = options.except(:categories) if complex_combo? && @options[:categories].blank?
|
|
702
|
+
options
|
|
620
703
|
end
|
|
621
704
|
|
|
622
705
|
# Scatter and bubble plot real x values. Handing Apex a `categories` array
|
|
@@ -781,10 +864,10 @@ module RailsuiCharts
|
|
|
781
864
|
timeline? || @type == :bar
|
|
782
865
|
end
|
|
783
866
|
|
|
784
|
-
#
|
|
785
|
-
#
|
|
786
|
-
def axis_ink(
|
|
787
|
-
|
|
867
|
+
# Series colour belongs to the marks and legend. Keep both scales neutral
|
|
868
|
+
# so the numbers stay readable and do not compete with the data.
|
|
869
|
+
def axis_ink(_index)
|
|
870
|
+
config_color(:text)
|
|
788
871
|
end
|
|
789
872
|
|
|
790
873
|
# Both sides fitted to their own values, then cut into the same number of
|
|
@@ -848,7 +931,7 @@ module RailsuiCharts
|
|
|
848
931
|
|
|
849
932
|
def axis_values(side)
|
|
850
933
|
values = @series.select { |series| axis_side(series) == side }
|
|
851
|
-
.flat_map { |series| series[:data].
|
|
934
|
+
.flat_map { |series| series[:data].flat_map { |point| Array(point[:y]) } }
|
|
852
935
|
# A column grows from a baseline, so its scale has to contain one. A line
|
|
853
936
|
# can float, and forcing zero on a churn rate hovering near 3% would
|
|
854
937
|
# flatten it against the top of the plot.
|
|
@@ -986,7 +1069,13 @@ module RailsuiCharts
|
|
|
986
1069
|
# A mixed chart takes its shape from each series, and Apex wants the
|
|
987
1070
|
# chart-level type to be "line" while they do — set to "bar" it draws
|
|
988
1071
|
# every series as bars whatever they asked for.
|
|
989
|
-
|
|
1072
|
+
if combo?
|
|
1073
|
+
complex = @series.map.with_index { |series, index| series_point_type(series, index) }
|
|
1074
|
+
.find { |type| RailsuiCharts.config.complex_point_types.include?(type) }
|
|
1075
|
+
return apex_type_for(complex) if complex
|
|
1076
|
+
|
|
1077
|
+
return "line"
|
|
1078
|
+
end
|
|
990
1079
|
|
|
991
1080
|
apex_type_for(@type)
|
|
992
1081
|
end
|
|
@@ -995,6 +1084,8 @@ module RailsuiCharts
|
|
|
995
1084
|
case type&.to_sym
|
|
996
1085
|
when :sparkline then "line"
|
|
997
1086
|
when :range_bar then "rangeBar"
|
|
1087
|
+
when :range_area then "rangeArea"
|
|
1088
|
+
when :box_plot then "boxPlot"
|
|
998
1089
|
when :column, :bar then "bar"
|
|
999
1090
|
when :donut then "donut"
|
|
1000
1091
|
when :polar_area then "polarArea"
|
|
@@ -6,6 +6,11 @@ module RailsuiCharts
|
|
|
6
6
|
CIRCULAR_TYPES = %i[pie donut polar_area radar].freeze
|
|
7
7
|
|
|
8
8
|
def railsui_chart(data, type: :line, **options)
|
|
9
|
+
# A chart whose series are not the numbers a reader wants can say so.
|
|
10
|
+
# Deleted rather than read, so it never reaches the builder and never
|
|
11
|
+
# gets serialised into the Stimulus value alongside the real config.
|
|
12
|
+
explicit_table = options.delete(:accessible_table)
|
|
13
|
+
|
|
9
14
|
# An empty dataset is a normal day one, not an error. Rendering axes
|
|
10
15
|
# around nothing looks like a chart that failed rather than a chart with
|
|
11
16
|
# nothing to show yet.
|
|
@@ -14,7 +19,14 @@ module RailsuiCharts
|
|
|
14
19
|
config = ApexOptionsBuilder.new(data, type: type, **options).build
|
|
15
20
|
id = options[:id] || "rui-chart-#{SecureRandom.hex(4)}"
|
|
16
21
|
|
|
17
|
-
table =
|
|
22
|
+
table =
|
|
23
|
+
if options[:accessible] == false
|
|
24
|
+
nil
|
|
25
|
+
elsif explicit_table.present?
|
|
26
|
+
explicit_accessibility_table(explicit_table, id: id)
|
|
27
|
+
else
|
|
28
|
+
accessibility_table(config, id: id)
|
|
29
|
+
end
|
|
18
30
|
|
|
19
31
|
content_tag(:div,
|
|
20
32
|
id: id,
|
|
@@ -151,7 +163,40 @@ module RailsuiCharts
|
|
|
151
163
|
end
|
|
152
164
|
|
|
153
165
|
# Every chart ships a visually hidden table so no value is reachable only by
|
|
154
|
-
# hovering a mark.
|
|
166
|
+
# hovering a mark. The hiding is the gem's own — `sr-only` rides along for
|
|
167
|
+
# apps that already style it, but a host utility cannot be relied on to
|
|
168
|
+
# exist, and Tailwind's does not actually hide a table. See
|
|
169
|
+
# railsui-chart-data-table in the stylesheet.
|
|
170
|
+
#
|
|
171
|
+
# The table a caller supplied, rather than one derived from the series.
|
|
172
|
+
#
|
|
173
|
+
# This is the accessible half of an extension point: a downstream helper
|
|
174
|
+
# that stacks scaffolding series to get a shape Apex can draw — a waterfall
|
|
175
|
+
# holding bars at a running total, say — can still tell a screen reader
|
|
176
|
+
# what the chart means. The CSV export is built from this table too, so it
|
|
177
|
+
# gets the same correction for free.
|
|
178
|
+
def explicit_accessibility_table(table, id:)
|
|
179
|
+
headers = Array(table[:headers] || table["headers"])
|
|
180
|
+
rows = Array(table[:rows] || table["rows"])
|
|
181
|
+
return if rows.blank?
|
|
182
|
+
|
|
183
|
+
content_tag(:table, class: "railsui-chart-data-table sr-only", aria: { label: "Chart data" }) do
|
|
184
|
+
safe_join([
|
|
185
|
+
content_tag(:caption, "Data for chart #{id}"),
|
|
186
|
+
content_tag(:thead) do
|
|
187
|
+
content_tag(:tr) { safe_join(headers.map { |header| content_tag(:th, header) }) }
|
|
188
|
+
end,
|
|
189
|
+
content_tag(:tbody) do
|
|
190
|
+
safe_join(rows.map do |row|
|
|
191
|
+
content_tag(:tr) do
|
|
192
|
+
safe_join(Array(row).map { |cell| content_tag(:td, cell_value(cell)) })
|
|
193
|
+
end
|
|
194
|
+
end)
|
|
195
|
+
end
|
|
196
|
+
])
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
155
200
|
def accessibility_table(config, id:)
|
|
156
201
|
series = config[:series]
|
|
157
202
|
return if series.blank?
|
|
@@ -163,13 +208,23 @@ module RailsuiCharts
|
|
|
163
208
|
else
|
|
164
209
|
columns = Array(series).map { |s| { name: s[:name] || "Value", data: s[:data] } }
|
|
165
210
|
categories = config.dig(:xaxis, :categories) || []
|
|
211
|
+
|
|
212
|
+
if categories.blank?
|
|
213
|
+
point_categories = accessibility_point_categories(columns)
|
|
214
|
+
if point_categories.present?
|
|
215
|
+
categories = point_categories
|
|
216
|
+
columns = columns.map do |column|
|
|
217
|
+
column.merge(data: accessibility_align_points(column[:data], categories))
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
166
221
|
end
|
|
167
222
|
comparison_categories = config[:compare_categories] || []
|
|
168
223
|
|
|
169
224
|
rows = columns.first&.dig(:data)
|
|
170
225
|
return if rows.blank?
|
|
171
226
|
|
|
172
|
-
content_tag(:table, class: "sr-only", aria: { label: "Chart data" }) do
|
|
227
|
+
content_tag(:table, class: "railsui-chart-data-table sr-only", aria: { label: "Chart data" }) do
|
|
173
228
|
safe_join([
|
|
174
229
|
content_tag(:caption, "Data for chart #{id}"),
|
|
175
230
|
content_tag(:thead) do
|
|
@@ -202,6 +257,20 @@ module RailsuiCharts
|
|
|
202
257
|
headers
|
|
203
258
|
end
|
|
204
259
|
|
|
260
|
+
def accessibility_point_categories(columns)
|
|
261
|
+
columns.flat_map do |column|
|
|
262
|
+
Array(column[:data]).filter_map { |value| value[:x] if value.is_a?(Hash) && value.key?(:x) }
|
|
263
|
+
end.uniq
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def accessibility_align_points(data, categories)
|
|
267
|
+
points = Array(data).each_with_object({}) do |value, indexed|
|
|
268
|
+
indexed[value[:x]] = value if value.is_a?(Hash) && value.key?(:x)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
categories.map { |category| points[category] }
|
|
272
|
+
end
|
|
273
|
+
|
|
205
274
|
def accessibility_category_cells(categories, comparison_categories, index)
|
|
206
275
|
cells = [content_tag(:td, categories[index] || index + 1)]
|
|
207
276
|
cells << content_tag(:td, comparison_categories[index] || index + 1) if comparison_categories.present?
|
|
@@ -215,7 +284,9 @@ module RailsuiCharts
|
|
|
215
284
|
# A bubble carries three numbers that all mean something here. Anything
|
|
216
285
|
# else keeps its label in the category column already, so repeating it
|
|
217
286
|
# beside the value just reads as "A, 6" to a screen reader.
|
|
218
|
-
|
|
287
|
+
return value.values_at(:x, :y, :z).compact.join(", ") if value.key?(:z)
|
|
288
|
+
|
|
289
|
+
cell_value(value[:y])
|
|
219
290
|
else value
|
|
220
291
|
end
|
|
221
292
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module RailsuiCharts
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :default_height, :default_currency, :colors, :series_colors, :theme_css_prefix, :extra_types
|
|
6
|
-
attr_accessor :labelled_point_types, :typography, :geometry
|
|
6
|
+
attr_accessor :labelled_point_types, :complex_point_types, :typography, :geometry
|
|
7
7
|
|
|
8
8
|
# Categorical hues carry identity, so the order is the colourblind-safety
|
|
9
9
|
# mechanism rather than a style choice — it was picked by validating every
|
|
@@ -27,6 +27,7 @@ module RailsuiCharts
|
|
|
27
27
|
# Chart types registered by an extension, such as Rails UI Charts Pro.
|
|
28
28
|
@extra_types = []
|
|
29
29
|
@labelled_point_types = []
|
|
30
|
+
@complex_point_types = []
|
|
30
31
|
@theme_css_prefix = "--rui-chart"
|
|
31
32
|
@series_colors = (1..SERIES_COUNT).map { |i| "var(--rui-chart-series-#{i}, #{SERIES_FALLBACKS[i - 1]})" }
|
|
32
33
|
@colors = {
|
|
@@ -76,11 +77,16 @@ module RailsuiCharts
|
|
|
76
77
|
# `points: :labelled` keeps each point as {x:, y:}. Most types put the label
|
|
77
78
|
# on the axis and send bare values, but a treemap draws its labels inside
|
|
78
79
|
# the rectangles, so they have to stay in the data.
|
|
80
|
+
#
|
|
81
|
+
# `points: :complex` keeps the whole point too, but for a different reason:
|
|
82
|
+
# some Apex types put several numbers in one y-value, such as OHLC candles,
|
|
83
|
+
# box plots, and range areas.
|
|
79
84
|
def register_type(type, points: :values)
|
|
80
85
|
type = type.to_sym
|
|
81
86
|
|
|
82
87
|
@extra_types |= [type]
|
|
83
88
|
@labelled_point_types |= [type] if points == :labelled
|
|
89
|
+
@complex_point_types |= [type] if points == :complex
|
|
84
90
|
|
|
85
91
|
type
|
|
86
92
|
end
|
|
@@ -146,27 +146,49 @@ module RailsuiCharts
|
|
|
146
146
|
def metric_dialog(label, value, change, previous, history, compare, format, positive_is_good, options)
|
|
147
147
|
content_tag(:dialog,
|
|
148
148
|
class: "railsui-metric-dialog",
|
|
149
|
-
data: {
|
|
149
|
+
data: {
|
|
150
|
+
"railsui-metric-dialog-target": "dialog",
|
|
151
|
+
action: "click->railsui-metric-dialog#closeOnBackdrop cancel->railsui-metric-dialog#cancel"
|
|
152
|
+
}) do
|
|
150
153
|
content_tag(:div, class: "railsui-metric-dialog__panel") do
|
|
151
154
|
safe_join([
|
|
152
155
|
content_tag(:div, class: "railsui-metric-dialog__head") do
|
|
153
156
|
safe_join([
|
|
154
157
|
metric_card_head(label, value, change, previous, format, positive_is_good),
|
|
155
|
-
content_tag(:button,
|
|
156
|
-
|
|
158
|
+
content_tag(:button, metric_dialog_close_icon, type: "button", class: "railsui-metric-dialog__close",
|
|
159
|
+
aria: { label: "Close" }, data: { action: "railsui-metric-dialog#close" })
|
|
157
160
|
])
|
|
158
161
|
end,
|
|
159
162
|
# Room to read values rather than just a trend, so the axis labels
|
|
160
|
-
# the card suppresses come back.
|
|
161
|
-
#
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
# the card suppresses come back. The header keeps the exact value;
|
|
164
|
+
# the axis uses compact labels so it stays legible inside the
|
|
165
|
+
# dialog without clipping at the canvas edge.
|
|
166
|
+
content_tag(:div, class: "railsui-metric-dialog__chart") do
|
|
167
|
+
railsui_chart(history, type: :line, compare: compare, format: metric_dialog_chart_format(format), height: 420,
|
|
168
|
+
axis: :right, legend: { show: compare.present? },
|
|
169
|
+
yaxis: { labels: { minWidth: 48, maxWidth: 72, offsetX: 4 } },
|
|
170
|
+
grid: { padding: { left: 32, right: 72 } }, **options)
|
|
171
|
+
end
|
|
165
172
|
])
|
|
166
173
|
end
|
|
167
174
|
end
|
|
168
175
|
end
|
|
169
176
|
|
|
177
|
+
def metric_dialog_chart_format(format)
|
|
178
|
+
format == :currency ? :short_currency : format
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def metric_dialog_close_icon
|
|
182
|
+
content_tag(:svg,
|
|
183
|
+
content_tag(:path, "", "stroke-linecap": "round", "stroke-linejoin": "round", d: "M6 18 18 6M6 6l12 12"),
|
|
184
|
+
class: "railsui-metric-dialog__close-icon",
|
|
185
|
+
viewBox: "0 0 24 24",
|
|
186
|
+
fill: "none",
|
|
187
|
+
stroke: "currentColor",
|
|
188
|
+
"stroke-width": 2,
|
|
189
|
+
aria: { hidden: true })
|
|
190
|
+
end
|
|
191
|
+
|
|
170
192
|
def format_metric_value(value, format)
|
|
171
193
|
case format
|
|
172
194
|
when :currency, :short_currency
|
data/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getrailsui/charts",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Rails-native chart components built on ApexCharts. Stimulus controllers for the railsui_charts gem.",
|
|
5
5
|
"author": "Andy Leverenz <railsui@justalever.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,15 +14,18 @@
|
|
|
14
14
|
"module": "app/javascript/railsui_charts/index.js",
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./app/javascript/railsui_charts/index.js",
|
|
17
|
+
"./styles.css": "./app/assets/stylesheets/railsui_charts.css",
|
|
17
18
|
"./controllers/*": "./app/javascript/controllers/*"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"app/javascript",
|
|
21
22
|
"app/assets/javascripts",
|
|
23
|
+
"app/assets/stylesheets",
|
|
22
24
|
"README.md",
|
|
23
25
|
"LICENSE.md",
|
|
24
26
|
"CHANGELOG.md"
|
|
25
27
|
],
|
|
28
|
+
"packageManager": "yarn@4.11.0",
|
|
26
29
|
"scripts": {
|
|
27
30
|
"build": "esbuild app/javascript/railsui_charts/index.js --bundle --format=esm --external:@hotwired/stimulus --external:apexcharts --outfile=app/assets/javascripts/railsui_charts.js"
|
|
28
31
|
},
|