railsui_charts 0.2.0 → 0.2.2
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 +24 -0
- data/README.md +5 -3
- data/app/assets/javascripts/railsui_charts.js +162 -5
- data/app/assets/stylesheets/railsui_charts.css +118 -45
- data/app/javascript/controllers/railsui_chart_controller.js +166 -1
- data/app/javascript/controllers/railsui_metric_dialog_controller.js +45 -6
- data/lib/railsui_charts/apex_options_builder.rb +115 -16
- data/lib/railsui_charts/chart_helper.rb +106 -11
- 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 +1 -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: 5be8bf55b84a7b5a3c7294a1b3958d5042e793efec37266956e2767a74cf1df0
|
|
4
|
+
data.tar.gz: 891215bb98875af325556b150d2c6e37db60cff1d71f68e62e174efeb4c51ac7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c422dda350f4a4b6ca52119fff256715589fda00df0421560b1d6e33b6b4c17162b26ec52f9e82502de5e05e8c33a6f503d5f427af0766b098c2b04b1d0e2ae
|
|
7
|
+
data.tar.gz: e48dee54260ae743336a05c646a8061f090e7c0ccba4fb5c8d82ab497440afacb8fe5772b67afc00aad62dc4cfdc1031fa6d7beda944bb6b959607f13a763d27
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ public API may change between minor versions.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.2]
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `accessible_table:` on `railsui_chart`, for charts whose series are not the
|
|
14
|
+
numbers a reader wants. A caller passes `{ headers:, rows: }` and it replaces
|
|
15
|
+
the table derived from the series. The CSV export in Rails UI Charts Pro is
|
|
16
|
+
built from that table, so it is corrected by the same option. This is what
|
|
17
|
+
lets a waterfall describe itself as movement rather than as the invisible
|
|
18
|
+
plinth it stacks to get the shape drawn.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Sparkline charts now use the Rails UI tooltip renderer, so hovered points show
|
|
23
|
+
their label and formatted value instead of an empty tooltip.
|
|
24
|
+
- Range-bar and labelled-point tooltips now read the hovered point from the
|
|
25
|
+
chart config, so timelines and treemaps show the right title, value, and
|
|
26
|
+
distributed color.
|
|
27
|
+
- Range-bar points keep their `name` and `meta` payloads for tooltip consumers.
|
|
28
|
+
- Chart loading skeletons now follow the same treatment as metric cards: they
|
|
29
|
+
reserve the footprint, show lightweight skeleton bars, and leave the plot area
|
|
30
|
+
empty until real data arrives. They align left by default and accept
|
|
31
|
+
`align: :center` or `align: :right` when a layout needs it.
|
|
32
|
+
|
|
9
33
|
## [0.2.0]
|
|
10
34
|
|
|
11
35
|
The JavaScript now comes from the gem instead of being copied into the
|
data/README.md
CHANGED
|
@@ -510,12 +510,14 @@ A series of zeroes is **not** empty. A quiet day still has something to say, and
|
|
|
510
510
|
|
|
511
511
|
```erb
|
|
512
512
|
<%= railsui_metric_card_skeleton chart_height: 180 %>
|
|
513
|
-
<%= railsui_chart_skeleton height: 240
|
|
513
|
+
<%= railsui_chart_skeleton height: 240 %>
|
|
514
514
|
```
|
|
515
515
|
|
|
516
516
|
Render one server-side and let a Turbo Stream swap in the real thing.
|
|
517
517
|
|
|
518
|
-
The
|
|
518
|
+
The loading helpers stand in for the text and chrome that are coming and leave the plot area empty. A slab where the chart goes claims more about the shape of the data than a loading state can know.
|
|
519
|
+
|
|
520
|
+
Chart skeletons align left by default. Pass `align: :center` or `align: :right` when a specific layout needs it.
|
|
519
521
|
|
|
520
522
|
On **refetch**, don't reach for the skeleton. Any element inside a container marked `aria-busy="true"` — which is what Turbo does to a frame while it loads — holds its previous render at reduced opacity instead. The numbers stay on screen and the layout stays still; a skeleton would throw the chart away and flash.
|
|
521
523
|
|
|
@@ -550,7 +552,7 @@ The Stimulus controller initializes charts on `connect` and destroys them on `di
|
|
|
550
552
|
|
|
551
553
|
## Full access
|
|
552
554
|
|
|
553
|
-
A Rails UI
|
|
555
|
+
A Rails UI subscription adds `railsui_charts_pro`, which builds on this gem
|
|
554
556
|
rather than replacing it:
|
|
555
557
|
|
|
556
558
|
- **Time series** — `TimeSeries` fills the gaps a `GROUP BY` leaves behind, buckets to any interval, and sums rows that collapse together
|
|
@@ -88,7 +88,7 @@ var railsui_chart_controller_default = class extends Controller {
|
|
|
88
88
|
// theme, and it leads with what changed rather than with the date.
|
|
89
89
|
applyTooltip(options) {
|
|
90
90
|
if (options.tooltip_style === false) return options;
|
|
91
|
-
if (options.tooltip?.custom
|
|
91
|
+
if (options.tooltip?.custom) return options;
|
|
92
92
|
const format = this.formatterFor(options.format || "number", options.currency);
|
|
93
93
|
const rowFormats = (options.series_formats || []).map((name) => this.formatterFor(name, options.currency));
|
|
94
94
|
const comparedDates = options.compare_categories || [];
|
|
@@ -101,6 +101,13 @@ var railsui_chart_controller_default = class extends Controller {
|
|
|
101
101
|
...options.tooltip || {},
|
|
102
102
|
custom: ({ series, seriesIndex, dataPointIndex, w }) => {
|
|
103
103
|
const labels = w.globals.categoryLabels?.length ? w.globals.categoryLabels : w.globals.labels;
|
|
104
|
+
if (options.chart?.type === "rangeBar") {
|
|
105
|
+
return this.rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format);
|
|
106
|
+
}
|
|
107
|
+
const configuredPoint = this.configuredPoint(options, w, seriesIndex, dataPointIndex);
|
|
108
|
+
if (this.labelledPoint(configuredPoint)) {
|
|
109
|
+
return this.labelledPointTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format);
|
|
110
|
+
}
|
|
104
111
|
if (!Array.isArray(series[0])) {
|
|
105
112
|
const slice = seriesIndex ?? dataPointIndex;
|
|
106
113
|
const row = { label: "", value: series[slice], color: w.globals.colors[slice], format: rowFormats[slice] || null };
|
|
@@ -124,6 +131,125 @@ var railsui_chart_controller_default = class extends Controller {
|
|
|
124
131
|
}
|
|
125
132
|
};
|
|
126
133
|
}
|
|
134
|
+
configuredPoint(options, w, seriesIndex, dataPointIndex) {
|
|
135
|
+
return (w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {}).data?.[dataPointIndex];
|
|
136
|
+
}
|
|
137
|
+
labelledPoint(point) {
|
|
138
|
+
return point !== null && typeof point === "object" && !Array.isArray(point) && "x" in point && "y" in point;
|
|
139
|
+
}
|
|
140
|
+
labelledPointTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
|
|
141
|
+
const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {};
|
|
142
|
+
const point = this.configuredPoint(options, w, seriesIndex, dataPointIndex) || {};
|
|
143
|
+
const title = point.name || point.label || point.x || configuredSeries.name;
|
|
144
|
+
if (Array.isArray(point.y)) {
|
|
145
|
+
return this.arrayPointTooltipMarkup(point, configuredSeries, options, w, seriesIndex, dataPointIndex, format);
|
|
146
|
+
}
|
|
147
|
+
const row = {
|
|
148
|
+
label: configuredSeries.name || "Value",
|
|
149
|
+
value: point.y ?? series?.[seriesIndex]?.[dataPointIndex],
|
|
150
|
+
color: this.pointColor(options, w, seriesIndex, dataPointIndex),
|
|
151
|
+
format: null
|
|
152
|
+
};
|
|
153
|
+
return this.tooltipMarkup(title, [row], null, format);
|
|
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
|
+
}
|
|
180
|
+
rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
|
|
181
|
+
const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {};
|
|
182
|
+
const point = configuredSeries.data?.[dataPointIndex] || {};
|
|
183
|
+
const fallbackValue = series?.[seriesIndex]?.[dataPointIndex];
|
|
184
|
+
const value = Array.isArray(point.y) ? point.y : fallbackValue;
|
|
185
|
+
const title = point.name || point.label || point.meta?.label || point.x || w.globals.categoryLabels?.[dataPointIndex] || w.globals.labels?.[dataPointIndex] || configuredSeries.name;
|
|
186
|
+
const rowLabel = point.name || point.label || point.meta?.label ? point.x || configuredSeries.name || "Range" : "Range";
|
|
187
|
+
const row = {
|
|
188
|
+
label: rowLabel,
|
|
189
|
+
value: this.formatRangeValue(value, options, format),
|
|
190
|
+
color: this.pointColor(options, w, seriesIndex, dataPointIndex),
|
|
191
|
+
format: null
|
|
192
|
+
};
|
|
193
|
+
return this.tooltipMarkup(title, [row], null, null);
|
|
194
|
+
}
|
|
195
|
+
pointColor(options, w, seriesIndex, dataPointIndex) {
|
|
196
|
+
if (options.plotOptions?.bar?.distributed && Array.isArray(options.colors)) {
|
|
197
|
+
return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex];
|
|
198
|
+
}
|
|
199
|
+
if (options.plotOptions?.treemap?.distributed && Array.isArray(options.colors)) {
|
|
200
|
+
return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex];
|
|
201
|
+
}
|
|
202
|
+
return w.globals.colors?.[seriesIndex] || options.colors?.[seriesIndex] || options.colors?.[0];
|
|
203
|
+
}
|
|
204
|
+
formatRangeValue(value, options, format) {
|
|
205
|
+
const values = Array.isArray(value) ? value : [value];
|
|
206
|
+
const present = values.filter((entry) => entry !== null && entry !== void 0);
|
|
207
|
+
if (present.length === 0) return null;
|
|
208
|
+
if (present.length === 2 && present.every((entry) => this.timestampLike(entry))) {
|
|
209
|
+
const [start, finish] = present;
|
|
210
|
+
if (this.sameTimestampDay(start, finish, options)) {
|
|
211
|
+
const startTime = this.formatTimestampTime(start, options);
|
|
212
|
+
const finishTime = this.formatTimestampTime(finish, options);
|
|
213
|
+
return startTime === finishTime ? `${this.formatTimestampDate(start, options)}, ${startTime}` : `${this.formatTimestampDate(start, options)}, ${startTime} - ${finishTime}`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const formatted = present.map((entry) => this.formatRangeEndpoint(entry, options, format));
|
|
217
|
+
if (formatted.length === 1 || formatted[0] === formatted[1]) return formatted[0];
|
|
218
|
+
return `${formatted[0]} - ${formatted[formatted.length - 1]}`;
|
|
219
|
+
}
|
|
220
|
+
formatRangeEndpoint(value, options, format) {
|
|
221
|
+
if (this.timestampLike(value)) return `${this.formatTimestampDate(value, options)}, ${this.formatTimestampTime(value, options)}`;
|
|
222
|
+
if (format) return format(value);
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
timestampLike(value) {
|
|
226
|
+
return typeof value === "number" && Number.isFinite(value) && Math.abs(value) >= 1e11;
|
|
227
|
+
}
|
|
228
|
+
sameTimestampDay(left, right, options) {
|
|
229
|
+
const leftDate = new Date(left);
|
|
230
|
+
const rightDate = new Date(right);
|
|
231
|
+
if (this.datetimeUTC(options)) {
|
|
232
|
+
return leftDate.toISOString().slice(0, 10) === rightDate.toISOString().slice(0, 10);
|
|
233
|
+
}
|
|
234
|
+
return leftDate.getFullYear() === rightDate.getFullYear() && leftDate.getMonth() === rightDate.getMonth() && leftDate.getDate() === rightDate.getDate();
|
|
235
|
+
}
|
|
236
|
+
formatTimestampDate(value, options) {
|
|
237
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
238
|
+
...this.datetimeUTC(options) ? { timeZone: "UTC" } : {},
|
|
239
|
+
month: "short",
|
|
240
|
+
day: "numeric"
|
|
241
|
+
}).format(new Date(value));
|
|
242
|
+
}
|
|
243
|
+
formatTimestampTime(value, options) {
|
|
244
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
245
|
+
...this.datetimeUTC(options) ? { timeZone: "UTC" } : {},
|
|
246
|
+
hour: "numeric",
|
|
247
|
+
minute: "2-digit"
|
|
248
|
+
}).format(new Date(value));
|
|
249
|
+
}
|
|
250
|
+
datetimeUTC(options) {
|
|
251
|
+
return options.xaxis?.labels?.datetimeUTC !== false;
|
|
252
|
+
}
|
|
127
253
|
tooltipDelta(current, previous, upIsGood) {
|
|
128
254
|
if (![current, previous].every((n) => typeof n === "number") || previous === 0) return null;
|
|
129
255
|
const change = (current - previous) / Math.abs(previous) * 100;
|
|
@@ -350,18 +476,49 @@ import { Controller as Controller3 } from "@hotwired/stimulus";
|
|
|
350
476
|
var railsui_metric_dialog_controller_default = class extends Controller3 {
|
|
351
477
|
static targets = ["dialog"];
|
|
352
478
|
open() {
|
|
479
|
+
this.dialogTarget.classList.remove("railsui-metric-dialog--closing");
|
|
353
480
|
this.dialogTarget.showModal();
|
|
354
|
-
|
|
355
|
-
|
|
481
|
+
requestAnimationFrame(() => {
|
|
482
|
+
this.dialogTarget.classList.add("railsui-metric-dialog--open");
|
|
483
|
+
this.refreshCharts();
|
|
356
484
|
});
|
|
357
485
|
}
|
|
358
486
|
close() {
|
|
359
|
-
this.
|
|
487
|
+
this.closeDialog();
|
|
488
|
+
}
|
|
489
|
+
cancel(event) {
|
|
490
|
+
event.preventDefault();
|
|
491
|
+
this.closeDialog();
|
|
360
492
|
}
|
|
361
493
|
// Clicking the backdrop lands on the dialog element itself; a click anywhere
|
|
362
494
|
// inside lands on a child.
|
|
363
495
|
closeOnBackdrop(event) {
|
|
364
|
-
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);
|
|
365
522
|
}
|
|
366
523
|
};
|
|
367
524
|
|
|
@@ -171,12 +171,22 @@
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
.railsui-chart-state--loading {
|
|
174
|
+
align-items: center;
|
|
175
|
+
justify-content: flex-start;
|
|
174
176
|
border-style: none;
|
|
175
|
-
padding:
|
|
177
|
+
padding: 1rem;
|
|
178
|
+
text-align: left;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.railsui-chart-state--loading-center {
|
|
182
|
+
justify-content: center;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.railsui-chart-state--loading-right {
|
|
186
|
+
justify-content: flex-end;
|
|
176
187
|
}
|
|
177
188
|
|
|
178
189
|
/* Shared placeholder surface: a fill plus the sheen that crosses it. */
|
|
179
|
-
.railsui-chart-skeleton,
|
|
180
190
|
.railsui-skeleton-bar {
|
|
181
191
|
position: relative;
|
|
182
192
|
overflow: hidden;
|
|
@@ -196,30 +206,33 @@
|
|
|
196
206
|
.railsui-skeleton-bar--meta { width: 7rem; height: 0.75rem; }
|
|
197
207
|
.railsui-skeleton-bar--footer { width: 6.5rem; height: 0.75rem; }
|
|
198
208
|
|
|
199
|
-
/*
|
|
200
|
-
|
|
209
|
+
/* Same rule as the metric card skeleton: stand in for surrounding text and
|
|
210
|
+
controls, but leave the plot area empty. A filled chart slab claims too much
|
|
211
|
+
about data that has not arrived. */
|
|
201
212
|
.railsui-chart-skeleton {
|
|
213
|
+
display: flex;
|
|
214
|
+
flex-direction: column;
|
|
215
|
+
width: clamp(18rem, 48%, 42rem);
|
|
216
|
+
max-width: 100%;
|
|
217
|
+
gap: 0.5rem;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.railsui-chart-skeleton .railsui-skeleton-bar--label { width: 36%; }
|
|
221
|
+
.railsui-chart-skeleton .railsui-skeleton-bar--value { width: 76%; }
|
|
222
|
+
.railsui-chart-skeleton .railsui-skeleton-bar--meta { width: 54%; }
|
|
223
|
+
.railsui-chart-skeleton .railsui-skeleton-bar--footer { width: 48%; }
|
|
224
|
+
|
|
225
|
+
.railsui-chart-skeleton__plot {
|
|
202
226
|
display: block;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
suggest the shape of what is coming without competing with the charts
|
|
209
|
-
already on screen around it. */
|
|
210
|
-
background-image: repeating-linear-gradient(
|
|
211
|
-
to bottom,
|
|
212
|
-
var(--rui-chart-skeleton-line) 0,
|
|
213
|
-
var(--rui-chart-skeleton-line) 1px,
|
|
214
|
-
transparent 1px,
|
|
215
|
-
transparent 25%
|
|
216
|
-
);
|
|
227
|
+
min-height: 4rem;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.railsui-chart-skeleton .railsui-skeleton-bar--footer {
|
|
231
|
+
margin-top: 0.25rem;
|
|
217
232
|
}
|
|
218
233
|
|
|
219
|
-
/* A sheen sweeping across, rather than the whole block breathing. Pulsing
|
|
220
|
-
|
|
221
|
-
as work in progress. */
|
|
222
|
-
.railsui-chart-skeleton::after,
|
|
234
|
+
/* A sheen sweeping across, rather than the whole block breathing. Pulsing reads
|
|
235
|
+
as a broken element; a sweep reads as work in progress. */
|
|
223
236
|
.railsui-skeleton-bar::after {
|
|
224
237
|
content: "";
|
|
225
238
|
position: absolute;
|
|
@@ -234,16 +247,6 @@
|
|
|
234
247
|
animation: railsui-chart-sweep 1.6s ease-in-out infinite;
|
|
235
248
|
}
|
|
236
249
|
|
|
237
|
-
/* Circular forms get a disc, so the placeholder is the shape of the thing
|
|
238
|
-
being waited on. */
|
|
239
|
-
.railsui-chart-skeleton--circular {
|
|
240
|
-
width: auto;
|
|
241
|
-
aspect-ratio: 1;
|
|
242
|
-
margin: 0 auto;
|
|
243
|
-
border-radius: 9999px;
|
|
244
|
-
background-image: none;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
250
|
@keyframes railsui-chart-sweep {
|
|
248
251
|
100% { transform: translateX(100%); }
|
|
249
252
|
}
|
|
@@ -259,7 +262,6 @@
|
|
|
259
262
|
|
|
260
263
|
@media (prefers-reduced-motion: reduce) {
|
|
261
264
|
/* The skeleton still reads as a placeholder standing still. */
|
|
262
|
-
.railsui-chart-skeleton::after,
|
|
263
265
|
.railsui-skeleton-bar::after {
|
|
264
266
|
animation: none;
|
|
265
267
|
opacity: 0.4;
|
|
@@ -516,6 +518,12 @@
|
|
|
516
518
|
margin-top: 0.5rem;
|
|
517
519
|
}
|
|
518
520
|
|
|
521
|
+
.railsui-metric-card__chart .railsui-chart,
|
|
522
|
+
.railsui-metric-card__chart .apexcharts-canvas,
|
|
523
|
+
.railsui-metric-card__chart .apexcharts-svg {
|
|
524
|
+
overflow: visible !important;
|
|
525
|
+
}
|
|
526
|
+
|
|
519
527
|
/* Rendered outside the plot so the first and last labels sit flush with the
|
|
520
528
|
card edges. Apex centres them on their data point and clips at the canvas
|
|
521
529
|
edge, which costs either the label's first characters or plot width. */
|
|
@@ -566,22 +574,41 @@
|
|
|
566
574
|
/* --------------------------------------------------------- expanded view */
|
|
567
575
|
|
|
568
576
|
.railsui-metric-dialog {
|
|
569
|
-
width: min(
|
|
570
|
-
max-height: calc(100vh -
|
|
577
|
+
width: min(64rem, calc(100vw - 2rem));
|
|
578
|
+
max-height: calc(100vh - 2rem);
|
|
571
579
|
padding: 0;
|
|
572
580
|
border: 1px solid var(--rui-chart-border);
|
|
573
581
|
border-radius: 0.75rem;
|
|
574
582
|
background-color: var(--rui-chart-surface);
|
|
575
583
|
color: var(--rui-chart-metric-value);
|
|
576
|
-
|
|
584
|
+
opacity: 0;
|
|
585
|
+
overflow: auto;
|
|
586
|
+
transform: translateY(0.5rem) scale(0.98);
|
|
587
|
+
transition: opacity 160ms ease, transform 180ms ease;
|
|
577
588
|
}
|
|
578
589
|
|
|
579
590
|
.railsui-metric-dialog::backdrop {
|
|
580
|
-
background-color:
|
|
591
|
+
background-color: transparent;
|
|
592
|
+
transition: background-color 160ms ease;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
.railsui-metric-dialog--open {
|
|
596
|
+
opacity: 1;
|
|
597
|
+
transform: translateY(0) scale(1);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.railsui-metric-dialog--open::backdrop {
|
|
601
|
+
background-color: rgba(15, 23, 42, 0.48);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
.railsui-metric-dialog--closing {
|
|
605
|
+
opacity: 0;
|
|
606
|
+
transform: translateY(0.25rem) scale(0.99);
|
|
581
607
|
}
|
|
582
608
|
|
|
583
609
|
.railsui-metric-dialog__panel {
|
|
584
|
-
|
|
610
|
+
min-width: 0;
|
|
611
|
+
padding: 1.25rem 1.75rem 1.5rem;
|
|
585
612
|
}
|
|
586
613
|
|
|
587
614
|
.railsui-metric-dialog__head {
|
|
@@ -593,21 +620,67 @@
|
|
|
593
620
|
}
|
|
594
621
|
|
|
595
622
|
.railsui-metric-dialog__close {
|
|
623
|
+
display: inline-flex;
|
|
624
|
+
align-items: center;
|
|
625
|
+
justify-content: center;
|
|
596
626
|
flex-shrink: 0;
|
|
597
|
-
width:
|
|
598
|
-
height:
|
|
627
|
+
width: 2.5rem;
|
|
628
|
+
height: 2.5rem;
|
|
599
629
|
border: 0;
|
|
600
|
-
border-radius:
|
|
630
|
+
border-radius: 0.375rem;
|
|
601
631
|
background-color: transparent;
|
|
602
632
|
color: var(--rui-chart-metric-muted);
|
|
603
|
-
font-size: 1.25rem;
|
|
604
|
-
line-height: 1;
|
|
605
633
|
cursor: pointer;
|
|
606
|
-
transition: background-color 120ms ease;
|
|
634
|
+
transition: background-color 120ms ease, color 120ms ease;
|
|
607
635
|
}
|
|
608
636
|
|
|
609
|
-
.railsui-metric-dialog__close:hover
|
|
637
|
+
.railsui-metric-dialog__close:hover,
|
|
638
|
+
.railsui-metric-dialog__close:focus-visible {
|
|
610
639
|
background-color: var(--rui-chart-hover);
|
|
640
|
+
color: var(--rui-chart-metric-value);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
.railsui-metric-dialog__close:focus-visible {
|
|
644
|
+
outline: 2px solid var(--rui-chart-primary);
|
|
645
|
+
outline-offset: 2px;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.railsui-metric-dialog__close-icon {
|
|
649
|
+
width: 1.5rem;
|
|
650
|
+
height: 1.5rem;
|
|
651
|
+
pointer-events: none;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
.railsui-metric-dialog__chart,
|
|
655
|
+
.railsui-metric-dialog__chart .railsui-chart,
|
|
656
|
+
.railsui-metric-dialog__chart .apexcharts-canvas,
|
|
657
|
+
.railsui-metric-dialog__chart .apexcharts-svg {
|
|
658
|
+
overflow: visible !important;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
@media (max-width: 640px) {
|
|
662
|
+
.railsui-metric-dialog {
|
|
663
|
+
width: calc(100vw - 1rem);
|
|
664
|
+
max-height: calc(100dvh - 1rem);
|
|
665
|
+
border-radius: 0.625rem;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
.railsui-metric-dialog__panel {
|
|
669
|
+
padding: 1rem;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.railsui-metric-dialog__head {
|
|
673
|
+
gap: 0.75rem;
|
|
674
|
+
margin-bottom: 0.75rem;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
@media (prefers-reduced-motion: reduce) {
|
|
679
|
+
.railsui-metric-dialog,
|
|
680
|
+
.railsui-metric-dialog::backdrop,
|
|
681
|
+
.railsui-metric-dialog__close {
|
|
682
|
+
transition: none;
|
|
683
|
+
}
|
|
611
684
|
}
|
|
612
685
|
|
|
613
686
|
/* ---------------------------------------------------------------- deltas */
|
|
@@ -113,7 +113,7 @@ export default class extends Controller {
|
|
|
113
113
|
// `tooltip_style: false` hands the tooltip back to Apex; passing your own
|
|
114
114
|
// `tooltip.custom` also wins.
|
|
115
115
|
if (options.tooltip_style === false) return options
|
|
116
|
-
if (options.tooltip?.custom
|
|
116
|
+
if (options.tooltip?.custom) return options
|
|
117
117
|
|
|
118
118
|
const format = this.formatterFor(options.format || "number", options.currency)
|
|
119
119
|
// One formatter per series on a combo, so a currency row and a percentage
|
|
@@ -131,6 +131,16 @@ export default class extends Controller {
|
|
|
131
131
|
custom: ({ series, seriesIndex, dataPointIndex, w }) => {
|
|
132
132
|
const labels = w.globals.categoryLabels?.length ? w.globals.categoryLabels : w.globals.labels
|
|
133
133
|
|
|
134
|
+
if (options.chart?.type === "rangeBar") {
|
|
135
|
+
return this.rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const configuredPoint = this.configuredPoint(options, w, seriesIndex, dataPointIndex)
|
|
139
|
+
|
|
140
|
+
if (this.labelledPoint(configuredPoint)) {
|
|
141
|
+
return this.labelledPointTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format)
|
|
142
|
+
}
|
|
143
|
+
|
|
134
144
|
// A pie, donut or polar area hands back one number per slice rather
|
|
135
145
|
// than one array per series, and names the hovered slice with
|
|
136
146
|
// seriesIndex — dataPointIndex means nothing there. Read as a
|
|
@@ -171,6 +181,161 @@ export default class extends Controller {
|
|
|
171
181
|
}
|
|
172
182
|
}
|
|
173
183
|
|
|
184
|
+
configuredPoint(options, w, seriesIndex, dataPointIndex) {
|
|
185
|
+
return (w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {}).data?.[dataPointIndex]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
labelledPoint(point) {
|
|
189
|
+
return point !== null && typeof point === "object" && !Array.isArray(point) && "x" in point && "y" in point
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
labelledPointTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
|
|
193
|
+
const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {}
|
|
194
|
+
const point = this.configuredPoint(options, w, seriesIndex, dataPointIndex) || {}
|
|
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
|
+
|
|
200
|
+
const row = {
|
|
201
|
+
label: configuredSeries.name || "Value",
|
|
202
|
+
value: point.y ?? series?.[seriesIndex]?.[dataPointIndex],
|
|
203
|
+
color: this.pointColor(options, w, seriesIndex, dataPointIndex),
|
|
204
|
+
format: null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return this.tooltipMarkup(title, [row], null, format)
|
|
208
|
+
}
|
|
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
|
+
|
|
240
|
+
rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
|
|
241
|
+
const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {}
|
|
242
|
+
const point = configuredSeries.data?.[dataPointIndex] || {}
|
|
243
|
+
const fallbackValue = series?.[seriesIndex]?.[dataPointIndex]
|
|
244
|
+
const value = Array.isArray(point.y) ? point.y : fallbackValue
|
|
245
|
+
const title = point.name || point.label || point.meta?.label || point.x || w.globals.categoryLabels?.[dataPointIndex] || w.globals.labels?.[dataPointIndex] || configuredSeries.name
|
|
246
|
+
const rowLabel = point.name || point.label || point.meta?.label ? (point.x || configuredSeries.name || "Range") : "Range"
|
|
247
|
+
|
|
248
|
+
const row = {
|
|
249
|
+
label: rowLabel,
|
|
250
|
+
value: this.formatRangeValue(value, options, format),
|
|
251
|
+
color: this.pointColor(options, w, seriesIndex, dataPointIndex),
|
|
252
|
+
format: null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return this.tooltipMarkup(title, [row], null, null)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
pointColor(options, w, seriesIndex, dataPointIndex) {
|
|
259
|
+
if (options.plotOptions?.bar?.distributed && Array.isArray(options.colors)) {
|
|
260
|
+
return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (options.plotOptions?.treemap?.distributed && Array.isArray(options.colors)) {
|
|
264
|
+
return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex]
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return w.globals.colors?.[seriesIndex] || options.colors?.[seriesIndex] || options.colors?.[0]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
formatRangeValue(value, options, format) {
|
|
271
|
+
const values = Array.isArray(value) ? value : [value]
|
|
272
|
+
const present = values.filter((entry) => entry !== null && entry !== undefined)
|
|
273
|
+
|
|
274
|
+
if (present.length === 0) return null
|
|
275
|
+
|
|
276
|
+
if (present.length === 2 && present.every((entry) => this.timestampLike(entry))) {
|
|
277
|
+
const [start, finish] = present
|
|
278
|
+
|
|
279
|
+
if (this.sameTimestampDay(start, finish, options)) {
|
|
280
|
+
const startTime = this.formatTimestampTime(start, options)
|
|
281
|
+
const finishTime = this.formatTimestampTime(finish, options)
|
|
282
|
+
|
|
283
|
+
return startTime === finishTime
|
|
284
|
+
? `${this.formatTimestampDate(start, options)}, ${startTime}`
|
|
285
|
+
: `${this.formatTimestampDate(start, options)}, ${startTime} - ${finishTime}`
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const formatted = present.map((entry) => this.formatRangeEndpoint(entry, options, format))
|
|
290
|
+
if (formatted.length === 1 || formatted[0] === formatted[1]) return formatted[0]
|
|
291
|
+
|
|
292
|
+
return `${formatted[0]} - ${formatted[formatted.length - 1]}`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
formatRangeEndpoint(value, options, format) {
|
|
296
|
+
if (this.timestampLike(value)) return `${this.formatTimestampDate(value, options)}, ${this.formatTimestampTime(value, options)}`
|
|
297
|
+
if (format) return format(value)
|
|
298
|
+
|
|
299
|
+
return value
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
timestampLike(value) {
|
|
303
|
+
return typeof value === "number" && Number.isFinite(value) && Math.abs(value) >= 100_000_000_000
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
sameTimestampDay(left, right, options) {
|
|
307
|
+
const leftDate = new Date(left)
|
|
308
|
+
const rightDate = new Date(right)
|
|
309
|
+
|
|
310
|
+
if (this.datetimeUTC(options)) {
|
|
311
|
+
return leftDate.toISOString().slice(0, 10) === rightDate.toISOString().slice(0, 10)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return leftDate.getFullYear() === rightDate.getFullYear()
|
|
315
|
+
&& leftDate.getMonth() === rightDate.getMonth()
|
|
316
|
+
&& leftDate.getDate() === rightDate.getDate()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
formatTimestampDate(value, options) {
|
|
320
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
321
|
+
...(this.datetimeUTC(options) ? { timeZone: "UTC" } : {}),
|
|
322
|
+
month: "short",
|
|
323
|
+
day: "numeric"
|
|
324
|
+
}).format(new Date(value))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
formatTimestampTime(value, options) {
|
|
328
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
329
|
+
...(this.datetimeUTC(options) ? { timeZone: "UTC" } : {}),
|
|
330
|
+
hour: "numeric",
|
|
331
|
+
minute: "2-digit"
|
|
332
|
+
}).format(new Date(value))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
datetimeUTC(options) {
|
|
336
|
+
return options.xaxis?.labels?.datetimeUTC !== false
|
|
337
|
+
}
|
|
338
|
+
|
|
174
339
|
tooltipDelta(current, previous, upIsGood) {
|
|
175
340
|
if (![current, previous].every((n) => typeof n === "number") || previous === 0) return null
|
|
176
341
|
|
|
@@ -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
|
}
|
|
@@ -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
|
|
@@ -152,7 +158,13 @@ module RailsuiCharts
|
|
|
152
158
|
case point
|
|
153
159
|
when Hash
|
|
154
160
|
point = point.symbolize_keys if point.respond_to?(:symbolize_keys)
|
|
155
|
-
{ x: point[:x], y: span_or_value(point), z: point[:z] }
|
|
161
|
+
normalized = { x: point[:x], y: span_or_value(point), z: point[:z] }
|
|
162
|
+
normalized[:meta] = point[:meta] if point.key?(:meta)
|
|
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)
|
|
167
|
+
normalized
|
|
156
168
|
when Array
|
|
157
169
|
{ x: point[0], y: point[1], z: point[2] }
|
|
158
170
|
else
|
|
@@ -171,9 +183,49 @@ module RailsuiCharts
|
|
|
171
183
|
end
|
|
172
184
|
|
|
173
185
|
def categories
|
|
186
|
+
return category_union if misaligned_series?
|
|
187
|
+
|
|
174
188
|
@data.map { |d| d[:x] }.compact
|
|
175
189
|
end
|
|
176
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
|
+
|
|
177
229
|
# A bare array of values carries no labels, and Apex sizes the x-axis from
|
|
178
230
|
# this list: given an empty one it draws nothing at all — no canvas, no
|
|
179
231
|
# error, just an element that stays empty. Positions stand in, which is
|
|
@@ -534,35 +586,66 @@ module RailsuiCharts
|
|
|
534
586
|
return series_values if circular?
|
|
535
587
|
|
|
536
588
|
plotted = @series.each_with_index.map do |series, index|
|
|
537
|
-
|
|
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) }
|
|
538
591
|
# Only on a combo. Naming a type on every series of an ordinary chart
|
|
539
592
|
# would override the chart-level one and quietly ignore `type:`.
|
|
540
|
-
entry[:type] =
|
|
593
|
+
entry[:type] = apex_type_for(point_type) if combo?
|
|
541
594
|
entry
|
|
542
595
|
end
|
|
543
596
|
|
|
544
597
|
comparing? ? plotted + [{ name: compare_label, data: @compare.map { |point| point[:y] } }] : plotted
|
|
545
598
|
end
|
|
546
599
|
|
|
547
|
-
def
|
|
548
|
-
|
|
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
|
|
549
606
|
when :scatter then points.map { |point| [point[:x], point[:y]] }
|
|
550
607
|
when :bubble then points.map { |point| { x: point[:x], y: point[:y], z: point[:z] || 1 } }
|
|
551
608
|
when :range_bar
|
|
552
609
|
# Both ends stay in the point. A range read off an axis is only half a
|
|
553
610
|
# range, and the label names the row rather than a position on a scale.
|
|
554
|
-
points.map
|
|
611
|
+
points.map do |point|
|
|
612
|
+
range = { x: point[:x], y: Array(point[:y]).map { |edge| range_edge(edge) } }
|
|
613
|
+
range[:meta] = point[:meta] if point.key?(:meta)
|
|
614
|
+
range[:name] = point[:name] if point.key?(:name)
|
|
615
|
+
range
|
|
616
|
+
end
|
|
555
617
|
else
|
|
556
618
|
# A registered type may draw its own labels rather than read them off an
|
|
557
619
|
# axis, in which case flattening the point to a value loses them.
|
|
558
|
-
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?
|
|
559
622
|
|
|
560
623
|
points.map { |point| point[:y] }
|
|
561
624
|
end
|
|
562
625
|
end
|
|
563
626
|
|
|
564
|
-
|
|
565
|
-
|
|
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
|
|
566
649
|
end
|
|
567
650
|
|
|
568
651
|
# Apex plots a range against a time axis in milliseconds. Handing it a Time
|
|
@@ -591,7 +674,7 @@ module RailsuiCharts
|
|
|
591
674
|
return { labels: { show: false } } if circular?
|
|
592
675
|
return numeric_axis(:x) if numeric_xaxis?
|
|
593
676
|
|
|
594
|
-
{
|
|
677
|
+
options = {
|
|
595
678
|
categories: axis_categories,
|
|
596
679
|
labels: {
|
|
597
680
|
show: categories.any? && !sparkline?,
|
|
@@ -609,6 +692,14 @@ module RailsuiCharts
|
|
|
609
692
|
tooltip: { enabled: false },
|
|
610
693
|
type: "category"
|
|
611
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
|
|
612
703
|
end
|
|
613
704
|
|
|
614
705
|
# Scatter and bubble plot real x values. Handing Apex a `categories` array
|
|
@@ -773,10 +864,10 @@ module RailsuiCharts
|
|
|
773
864
|
timeline? || @type == :bar
|
|
774
865
|
end
|
|
775
866
|
|
|
776
|
-
#
|
|
777
|
-
#
|
|
778
|
-
def axis_ink(
|
|
779
|
-
|
|
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)
|
|
780
871
|
end
|
|
781
872
|
|
|
782
873
|
# Both sides fitted to their own values, then cut into the same number of
|
|
@@ -840,7 +931,7 @@ module RailsuiCharts
|
|
|
840
931
|
|
|
841
932
|
def axis_values(side)
|
|
842
933
|
values = @series.select { |series| axis_side(series) == side }
|
|
843
|
-
.flat_map { |series| series[:data].
|
|
934
|
+
.flat_map { |series| series[:data].flat_map { |point| Array(point[:y]) } }
|
|
844
935
|
# A column grows from a baseline, so its scale has to contain one. A line
|
|
845
936
|
# can float, and forcing zero on a churn rate hovering near 3% would
|
|
846
937
|
# flatten it against the top of the plot.
|
|
@@ -978,7 +1069,13 @@ module RailsuiCharts
|
|
|
978
1069
|
# A mixed chart takes its shape from each series, and Apex wants the
|
|
979
1070
|
# chart-level type to be "line" while they do — set to "bar" it draws
|
|
980
1071
|
# every series as bars whatever they asked for.
|
|
981
|
-
|
|
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
|
|
982
1079
|
|
|
983
1080
|
apex_type_for(@type)
|
|
984
1081
|
end
|
|
@@ -987,6 +1084,8 @@ module RailsuiCharts
|
|
|
987
1084
|
case type&.to_sym
|
|
988
1085
|
when :sparkline then "line"
|
|
989
1086
|
when :range_bar then "rangeBar"
|
|
1087
|
+
when :range_area then "rangeArea"
|
|
1088
|
+
when :box_plot then "boxPlot"
|
|
990
1089
|
when :column, :bar then "bar"
|
|
991
1090
|
when :donut then "donut"
|
|
992
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,
|
|
@@ -80,23 +92,38 @@ module RailsuiCharts
|
|
|
80
92
|
end
|
|
81
93
|
|
|
82
94
|
# Server-rendered placeholder for a chart whose data has not arrived yet —
|
|
83
|
-
# the thing a Turbo frame shows before it swaps in the real one.
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
# the thing a Turbo frame shows before it swaps in the real one. It keeps
|
|
96
|
+
# the chart footprint and avoids drawing fake chart geometry before data
|
|
97
|
+
# exists.
|
|
98
|
+
def railsui_chart_skeleton(height: nil, type: :line, label: "Loading chart", align: :left)
|
|
99
|
+
alignment = skeleton_alignment(align)
|
|
88
100
|
|
|
89
101
|
content_tag(:div,
|
|
90
|
-
class: "railsui-chart-state railsui-chart-state--loading",
|
|
102
|
+
class: "railsui-chart-state railsui-chart-state--loading railsui-chart-state--loading-#{alignment}",
|
|
91
103
|
style: state_height(height),
|
|
92
104
|
role: "status",
|
|
93
105
|
aria: { label: label, busy: true }) do
|
|
94
|
-
content_tag(:
|
|
106
|
+
content_tag(:div, class: "railsui-chart-skeleton", aria: { hidden: true }) do
|
|
107
|
+
safe_join([
|
|
108
|
+
content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--label"),
|
|
109
|
+
content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--value"),
|
|
110
|
+
content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--meta"),
|
|
111
|
+
content_tag(:span, "", class: "railsui-chart-skeleton__plot"),
|
|
112
|
+
content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--footer")
|
|
113
|
+
])
|
|
114
|
+
end
|
|
95
115
|
end
|
|
96
116
|
end
|
|
97
117
|
|
|
98
118
|
private
|
|
99
119
|
|
|
120
|
+
def skeleton_alignment(align)
|
|
121
|
+
alignment = align.to_sym
|
|
122
|
+
return alignment if %i[left center right].include?(alignment)
|
|
123
|
+
|
|
124
|
+
raise ArgumentError, "Unsupported skeleton alignment: #{align}. Supported: left, center, right"
|
|
125
|
+
end
|
|
126
|
+
|
|
100
127
|
def chart_state(kind, title:, description:, height:)
|
|
101
128
|
content_tag(:div,
|
|
102
129
|
class: "railsui-chart-state railsui-chart-state--#{kind}",
|
|
@@ -137,6 +164,35 @@ module RailsuiCharts
|
|
|
137
164
|
|
|
138
165
|
# Every chart ships a visually hidden table so no value is reachable only by
|
|
139
166
|
# hovering a mark.
|
|
167
|
+
# The table a caller supplied, rather than one derived from the series.
|
|
168
|
+
#
|
|
169
|
+
# This is the accessible half of an extension point: a downstream helper
|
|
170
|
+
# that stacks scaffolding series to get a shape Apex can draw — a waterfall
|
|
171
|
+
# holding bars at a running total, say — can still tell a screen reader
|
|
172
|
+
# what the chart means. The CSV export is built from this table too, so it
|
|
173
|
+
# gets the same correction for free.
|
|
174
|
+
def explicit_accessibility_table(table, id:)
|
|
175
|
+
headers = Array(table[:headers] || table["headers"])
|
|
176
|
+
rows = Array(table[:rows] || table["rows"])
|
|
177
|
+
return if rows.blank?
|
|
178
|
+
|
|
179
|
+
content_tag(:table, class: "sr-only", aria: { label: "Chart data" }) do
|
|
180
|
+
safe_join([
|
|
181
|
+
content_tag(:caption, "Data for chart #{id}"),
|
|
182
|
+
content_tag(:thead) do
|
|
183
|
+
content_tag(:tr) { safe_join(headers.map { |header| content_tag(:th, header) }) }
|
|
184
|
+
end,
|
|
185
|
+
content_tag(:tbody) do
|
|
186
|
+
safe_join(rows.map do |row|
|
|
187
|
+
content_tag(:tr) do
|
|
188
|
+
safe_join(Array(row).map { |cell| content_tag(:td, cell_value(cell)) })
|
|
189
|
+
end
|
|
190
|
+
end)
|
|
191
|
+
end
|
|
192
|
+
])
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
140
196
|
def accessibility_table(config, id:)
|
|
141
197
|
series = config[:series]
|
|
142
198
|
return if series.blank?
|
|
@@ -148,7 +204,18 @@ module RailsuiCharts
|
|
|
148
204
|
else
|
|
149
205
|
columns = Array(series).map { |s| { name: s[:name] || "Value", data: s[:data] } }
|
|
150
206
|
categories = config.dig(:xaxis, :categories) || []
|
|
207
|
+
|
|
208
|
+
if categories.blank?
|
|
209
|
+
point_categories = accessibility_point_categories(columns)
|
|
210
|
+
if point_categories.present?
|
|
211
|
+
categories = point_categories
|
|
212
|
+
columns = columns.map do |column|
|
|
213
|
+
column.merge(data: accessibility_align_points(column[:data], categories))
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
151
217
|
end
|
|
218
|
+
comparison_categories = config[:compare_categories] || []
|
|
152
219
|
|
|
153
220
|
rows = columns.first&.dig(:data)
|
|
154
221
|
return if rows.blank?
|
|
@@ -159,7 +226,7 @@ module RailsuiCharts
|
|
|
159
226
|
content_tag(:thead) do
|
|
160
227
|
content_tag(:tr) do
|
|
161
228
|
safe_join(
|
|
162
|
-
|
|
229
|
+
accessibility_category_headers(comparison_categories) +
|
|
163
230
|
columns.map { |column| content_tag(:th, column[:name]) }
|
|
164
231
|
)
|
|
165
232
|
end
|
|
@@ -169,7 +236,7 @@ module RailsuiCharts
|
|
|
169
236
|
rows.each_with_index.map do |_, index|
|
|
170
237
|
content_tag(:tr) do
|
|
171
238
|
safe_join(
|
|
172
|
-
|
|
239
|
+
accessibility_category_cells(categories, comparison_categories, index) +
|
|
173
240
|
columns.map { |column| content_tag(:td, cell_value(column[:data][index])) }
|
|
174
241
|
)
|
|
175
242
|
end
|
|
@@ -180,6 +247,32 @@ module RailsuiCharts
|
|
|
180
247
|
end
|
|
181
248
|
end
|
|
182
249
|
|
|
250
|
+
def accessibility_category_headers(comparison_categories)
|
|
251
|
+
headers = [content_tag(:th, "Category")]
|
|
252
|
+
headers << content_tag(:th, "Comparison category") if comparison_categories.present?
|
|
253
|
+
headers
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def accessibility_point_categories(columns)
|
|
257
|
+
columns.flat_map do |column|
|
|
258
|
+
Array(column[:data]).filter_map { |value| value[:x] if value.is_a?(Hash) && value.key?(:x) }
|
|
259
|
+
end.uniq
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def accessibility_align_points(data, categories)
|
|
263
|
+
points = Array(data).each_with_object({}) do |value, indexed|
|
|
264
|
+
indexed[value[:x]] = value if value.is_a?(Hash) && value.key?(:x)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
categories.map { |category| points[category] }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def accessibility_category_cells(categories, comparison_categories, index)
|
|
271
|
+
cells = [content_tag(:td, categories[index] || index + 1)]
|
|
272
|
+
cells << content_tag(:td, comparison_categories[index] || index + 1) if comparison_categories.present?
|
|
273
|
+
cells
|
|
274
|
+
end
|
|
275
|
+
|
|
183
276
|
def cell_value(value)
|
|
184
277
|
case value
|
|
185
278
|
when Array then value.join(", ")
|
|
@@ -187,7 +280,9 @@ module RailsuiCharts
|
|
|
187
280
|
# A bubble carries three numbers that all mean something here. Anything
|
|
188
281
|
# else keeps its label in the category column already, so repeating it
|
|
189
282
|
# beside the value just reads as "A, 6" to a screen reader.
|
|
190
|
-
|
|
283
|
+
return value.values_at(:x, :y, :z).compact.join(", ") if value.key?(:z)
|
|
284
|
+
|
|
285
|
+
cell_value(value[:y])
|
|
191
286
|
else value
|
|
192
287
|
end
|
|
193
288
|
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.2",
|
|
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",
|