railsui_charts 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +5 -3
- data/app/assets/javascripts/railsui_charts.js +99 -1
- data/app/assets/stylesheets/railsui_charts.css +35 -33
- data/app/javascript/controllers/railsui_chart_controller.js +132 -1
- data/lib/railsui_charts/apex_options_builder.rb +10 -2
- data/lib/railsui_charts/chart_helper.rb +37 -9
- 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: '049e45e39a745fa833e9ba7835a09ecf960cb837b5d86c07c48d311201cddd6e'
|
|
4
|
+
data.tar.gz: 5b649ab130a83f43fa87490699fc5514eb00ee9eb92d5bdf6d059e31ac61f73e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9d86406916a9fc276372ae9f200f0a0b7fd4d27327caa0e56264d1e79f7eb315c1dde4293c2c275da3f63d02ba47df38524c8d7cb3119ce7204cb36cf5754580
|
|
7
|
+
data.tar.gz: 2aacd7f09219f90924d07f5412fc4a25e62fcf312bd4903817138fea5f326d0d1ba48ec3169c416ac5e94141d43dd801c47dde340acc2a7b432c1761abd0aee8
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,19 @@ public API may change between minor versions.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Sparkline charts now use the Rails UI tooltip renderer, so hovered points show
|
|
12
|
+
their label and formatted value instead of an empty tooltip.
|
|
13
|
+
- Range-bar and labelled-point tooltips now read the hovered point from the
|
|
14
|
+
chart config, so timelines and treemaps show the right title, value, and
|
|
15
|
+
distributed color.
|
|
16
|
+
- Range-bar points keep their `name` and `meta` payloads for tooltip consumers.
|
|
17
|
+
- Chart loading skeletons now follow the same treatment as metric cards: they
|
|
18
|
+
reserve the footprint, show lightweight skeleton bars, and leave the plot area
|
|
19
|
+
empty until real data arrives. They align left by default and accept
|
|
20
|
+
`align: :center` or `align: :right` when a layout needs it.
|
|
21
|
+
|
|
9
22
|
## [0.2.0]
|
|
10
23
|
|
|
11
24
|
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,97 @@ 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
|
+
const row = {
|
|
145
|
+
label: configuredSeries.name || "Value",
|
|
146
|
+
value: point.y ?? series?.[seriesIndex]?.[dataPointIndex],
|
|
147
|
+
color: this.pointColor(options, w, seriesIndex, dataPointIndex),
|
|
148
|
+
format: null
|
|
149
|
+
};
|
|
150
|
+
return this.tooltipMarkup(title, [row], null, format);
|
|
151
|
+
}
|
|
152
|
+
rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
|
|
153
|
+
const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {};
|
|
154
|
+
const point = configuredSeries.data?.[dataPointIndex] || {};
|
|
155
|
+
const fallbackValue = series?.[seriesIndex]?.[dataPointIndex];
|
|
156
|
+
const value = Array.isArray(point.y) ? point.y : fallbackValue;
|
|
157
|
+
const title = point.name || point.label || point.meta?.label || point.x || w.globals.categoryLabels?.[dataPointIndex] || w.globals.labels?.[dataPointIndex] || configuredSeries.name;
|
|
158
|
+
const rowLabel = point.name || point.label || point.meta?.label ? point.x || configuredSeries.name || "Range" : "Range";
|
|
159
|
+
const row = {
|
|
160
|
+
label: rowLabel,
|
|
161
|
+
value: this.formatRangeValue(value, options, format),
|
|
162
|
+
color: this.pointColor(options, w, seriesIndex, dataPointIndex),
|
|
163
|
+
format: null
|
|
164
|
+
};
|
|
165
|
+
return this.tooltipMarkup(title, [row], null, null);
|
|
166
|
+
}
|
|
167
|
+
pointColor(options, w, seriesIndex, dataPointIndex) {
|
|
168
|
+
if (options.plotOptions?.bar?.distributed && Array.isArray(options.colors)) {
|
|
169
|
+
return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex];
|
|
170
|
+
}
|
|
171
|
+
if (options.plotOptions?.treemap?.distributed && Array.isArray(options.colors)) {
|
|
172
|
+
return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex];
|
|
173
|
+
}
|
|
174
|
+
return w.globals.colors?.[seriesIndex] || options.colors?.[seriesIndex] || options.colors?.[0];
|
|
175
|
+
}
|
|
176
|
+
formatRangeValue(value, options, format) {
|
|
177
|
+
const values = Array.isArray(value) ? value : [value];
|
|
178
|
+
const present = values.filter((entry) => entry !== null && entry !== void 0);
|
|
179
|
+
if (present.length === 0) return null;
|
|
180
|
+
if (present.length === 2 && present.every((entry) => this.timestampLike(entry))) {
|
|
181
|
+
const [start, finish] = present;
|
|
182
|
+
if (this.sameTimestampDay(start, finish, options)) {
|
|
183
|
+
const startTime = this.formatTimestampTime(start, options);
|
|
184
|
+
const finishTime = this.formatTimestampTime(finish, options);
|
|
185
|
+
return startTime === finishTime ? `${this.formatTimestampDate(start, options)}, ${startTime}` : `${this.formatTimestampDate(start, options)}, ${startTime} - ${finishTime}`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const formatted = present.map((entry) => this.formatRangeEndpoint(entry, options, format));
|
|
189
|
+
if (formatted.length === 1 || formatted[0] === formatted[1]) return formatted[0];
|
|
190
|
+
return `${formatted[0]} - ${formatted[formatted.length - 1]}`;
|
|
191
|
+
}
|
|
192
|
+
formatRangeEndpoint(value, options, format) {
|
|
193
|
+
if (this.timestampLike(value)) return `${this.formatTimestampDate(value, options)}, ${this.formatTimestampTime(value, options)}`;
|
|
194
|
+
if (format) return format(value);
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
timestampLike(value) {
|
|
198
|
+
return typeof value === "number" && Number.isFinite(value) && Math.abs(value) >= 1e11;
|
|
199
|
+
}
|
|
200
|
+
sameTimestampDay(left, right, options) {
|
|
201
|
+
const leftDate = new Date(left);
|
|
202
|
+
const rightDate = new Date(right);
|
|
203
|
+
if (this.datetimeUTC(options)) {
|
|
204
|
+
return leftDate.toISOString().slice(0, 10) === rightDate.toISOString().slice(0, 10);
|
|
205
|
+
}
|
|
206
|
+
return leftDate.getFullYear() === rightDate.getFullYear() && leftDate.getMonth() === rightDate.getMonth() && leftDate.getDate() === rightDate.getDate();
|
|
207
|
+
}
|
|
208
|
+
formatTimestampDate(value, options) {
|
|
209
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
210
|
+
...this.datetimeUTC(options) ? { timeZone: "UTC" } : {},
|
|
211
|
+
month: "short",
|
|
212
|
+
day: "numeric"
|
|
213
|
+
}).format(new Date(value));
|
|
214
|
+
}
|
|
215
|
+
formatTimestampTime(value, options) {
|
|
216
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
217
|
+
...this.datetimeUTC(options) ? { timeZone: "UTC" } : {},
|
|
218
|
+
hour: "numeric",
|
|
219
|
+
minute: "2-digit"
|
|
220
|
+
}).format(new Date(value));
|
|
221
|
+
}
|
|
222
|
+
datetimeUTC(options) {
|
|
223
|
+
return options.xaxis?.labels?.datetimeUTC !== false;
|
|
224
|
+
}
|
|
127
225
|
tooltipDelta(current, previous, upIsGood) {
|
|
128
226
|
if (![current, previous].every((n) => typeof n === "number") || previous === 0) return null;
|
|
129
227
|
const change = (current - previous) / Math.abs(previous) * 100;
|
|
@@ -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
|
-
min-height: inherit;
|
|
205
|
-
align-self: stretch;
|
|
206
|
-
border-radius: 0.5rem;
|
|
207
|
-
/* Its own line colour, softer than a real gridline. A placeholder should
|
|
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;
|
|
217
228
|
}
|
|
218
229
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
230
|
+
.railsui-chart-skeleton .railsui-skeleton-bar--footer {
|
|
231
|
+
margin-top: 0.25rem;
|
|
232
|
+
}
|
|
233
|
+
|
|
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;
|
|
@@ -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,127 @@ 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
|
+
const row = {
|
|
197
|
+
label: configuredSeries.name || "Value",
|
|
198
|
+
value: point.y ?? series?.[seriesIndex]?.[dataPointIndex],
|
|
199
|
+
color: this.pointColor(options, w, seriesIndex, dataPointIndex),
|
|
200
|
+
format: null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return this.tooltipMarkup(title, [row], null, format)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
rangeBarTooltipMarkup({ series, seriesIndex, dataPointIndex, w }, options, format) {
|
|
207
|
+
const configuredSeries = w.config?.series?.[seriesIndex] || options.series?.[seriesIndex] || {}
|
|
208
|
+
const point = configuredSeries.data?.[dataPointIndex] || {}
|
|
209
|
+
const fallbackValue = series?.[seriesIndex]?.[dataPointIndex]
|
|
210
|
+
const value = Array.isArray(point.y) ? point.y : fallbackValue
|
|
211
|
+
const title = point.name || point.label || point.meta?.label || point.x || w.globals.categoryLabels?.[dataPointIndex] || w.globals.labels?.[dataPointIndex] || configuredSeries.name
|
|
212
|
+
const rowLabel = point.name || point.label || point.meta?.label ? (point.x || configuredSeries.name || "Range") : "Range"
|
|
213
|
+
|
|
214
|
+
const row = {
|
|
215
|
+
label: rowLabel,
|
|
216
|
+
value: this.formatRangeValue(value, options, format),
|
|
217
|
+
color: this.pointColor(options, w, seriesIndex, dataPointIndex),
|
|
218
|
+
format: null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return this.tooltipMarkup(title, [row], null, null)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
pointColor(options, w, seriesIndex, dataPointIndex) {
|
|
225
|
+
if (options.plotOptions?.bar?.distributed && Array.isArray(options.colors)) {
|
|
226
|
+
return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (options.plotOptions?.treemap?.distributed && Array.isArray(options.colors)) {
|
|
230
|
+
return options.colors[dataPointIndex] || w.globals.colors?.[dataPointIndex] || w.globals.colors?.[seriesIndex]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return w.globals.colors?.[seriesIndex] || options.colors?.[seriesIndex] || options.colors?.[0]
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
formatRangeValue(value, options, format) {
|
|
237
|
+
const values = Array.isArray(value) ? value : [value]
|
|
238
|
+
const present = values.filter((entry) => entry !== null && entry !== undefined)
|
|
239
|
+
|
|
240
|
+
if (present.length === 0) return null
|
|
241
|
+
|
|
242
|
+
if (present.length === 2 && present.every((entry) => this.timestampLike(entry))) {
|
|
243
|
+
const [start, finish] = present
|
|
244
|
+
|
|
245
|
+
if (this.sameTimestampDay(start, finish, options)) {
|
|
246
|
+
const startTime = this.formatTimestampTime(start, options)
|
|
247
|
+
const finishTime = this.formatTimestampTime(finish, options)
|
|
248
|
+
|
|
249
|
+
return startTime === finishTime
|
|
250
|
+
? `${this.formatTimestampDate(start, options)}, ${startTime}`
|
|
251
|
+
: `${this.formatTimestampDate(start, options)}, ${startTime} - ${finishTime}`
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const formatted = present.map((entry) => this.formatRangeEndpoint(entry, options, format))
|
|
256
|
+
if (formatted.length === 1 || formatted[0] === formatted[1]) return formatted[0]
|
|
257
|
+
|
|
258
|
+
return `${formatted[0]} - ${formatted[formatted.length - 1]}`
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
formatRangeEndpoint(value, options, format) {
|
|
262
|
+
if (this.timestampLike(value)) return `${this.formatTimestampDate(value, options)}, ${this.formatTimestampTime(value, options)}`
|
|
263
|
+
if (format) return format(value)
|
|
264
|
+
|
|
265
|
+
return value
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
timestampLike(value) {
|
|
269
|
+
return typeof value === "number" && Number.isFinite(value) && Math.abs(value) >= 100_000_000_000
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
sameTimestampDay(left, right, options) {
|
|
273
|
+
const leftDate = new Date(left)
|
|
274
|
+
const rightDate = new Date(right)
|
|
275
|
+
|
|
276
|
+
if (this.datetimeUTC(options)) {
|
|
277
|
+
return leftDate.toISOString().slice(0, 10) === rightDate.toISOString().slice(0, 10)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return leftDate.getFullYear() === rightDate.getFullYear()
|
|
281
|
+
&& leftDate.getMonth() === rightDate.getMonth()
|
|
282
|
+
&& leftDate.getDate() === rightDate.getDate()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
formatTimestampDate(value, options) {
|
|
286
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
287
|
+
...(this.datetimeUTC(options) ? { timeZone: "UTC" } : {}),
|
|
288
|
+
month: "short",
|
|
289
|
+
day: "numeric"
|
|
290
|
+
}).format(new Date(value))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
formatTimestampTime(value, options) {
|
|
294
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
295
|
+
...(this.datetimeUTC(options) ? { timeZone: "UTC" } : {}),
|
|
296
|
+
hour: "numeric",
|
|
297
|
+
minute: "2-digit"
|
|
298
|
+
}).format(new Date(value))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
datetimeUTC(options) {
|
|
302
|
+
return options.xaxis?.labels?.datetimeUTC !== false
|
|
303
|
+
}
|
|
304
|
+
|
|
174
305
|
tooltipDelta(current, previous, upIsGood) {
|
|
175
306
|
if (![current, previous].every((n) => typeof n === "number") || previous === 0) return null
|
|
176
307
|
|
|
@@ -152,7 +152,10 @@ module RailsuiCharts
|
|
|
152
152
|
case point
|
|
153
153
|
when Hash
|
|
154
154
|
point = point.symbolize_keys if point.respond_to?(:symbolize_keys)
|
|
155
|
-
{ x: point[:x], y: span_or_value(point), z: point[:z] }
|
|
155
|
+
normalized = { x: point[:x], y: span_or_value(point), z: point[:z] }
|
|
156
|
+
normalized[:meta] = point[:meta] if point.key?(:meta)
|
|
157
|
+
normalized[:name] = point[:name] if point.key?(:name)
|
|
158
|
+
normalized
|
|
156
159
|
when Array
|
|
157
160
|
{ x: point[0], y: point[1], z: point[2] }
|
|
158
161
|
else
|
|
@@ -551,7 +554,12 @@ module RailsuiCharts
|
|
|
551
554
|
when :range_bar
|
|
552
555
|
# Both ends stay in the point. A range read off an axis is only half a
|
|
553
556
|
# range, and the label names the row rather than a position on a scale.
|
|
554
|
-
points.map
|
|
557
|
+
points.map do |point|
|
|
558
|
+
range = { x: point[:x], y: Array(point[:y]).map { |edge| range_edge(edge) } }
|
|
559
|
+
range[:meta] = point[:meta] if point.key?(:meta)
|
|
560
|
+
range[:name] = point[:name] if point.key?(:name)
|
|
561
|
+
range
|
|
562
|
+
end
|
|
555
563
|
else
|
|
556
564
|
# A registered type may draw its own labels rather than read them off an
|
|
557
565
|
# axis, in which case flattening the point to a value loses them.
|
|
@@ -80,23 +80,38 @@ module RailsuiCharts
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
# 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
|
-
|
|
83
|
+
# the thing a Turbo frame shows before it swaps in the real one. It keeps
|
|
84
|
+
# the chart footprint and avoids drawing fake chart geometry before data
|
|
85
|
+
# exists.
|
|
86
|
+
def railsui_chart_skeleton(height: nil, type: :line, label: "Loading chart", align: :left)
|
|
87
|
+
alignment = skeleton_alignment(align)
|
|
88
88
|
|
|
89
89
|
content_tag(:div,
|
|
90
|
-
class: "railsui-chart-state railsui-chart-state--loading",
|
|
90
|
+
class: "railsui-chart-state railsui-chart-state--loading railsui-chart-state--loading-#{alignment}",
|
|
91
91
|
style: state_height(height),
|
|
92
92
|
role: "status",
|
|
93
93
|
aria: { label: label, busy: true }) do
|
|
94
|
-
content_tag(:
|
|
94
|
+
content_tag(:div, class: "railsui-chart-skeleton", aria: { hidden: true }) do
|
|
95
|
+
safe_join([
|
|
96
|
+
content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--label"),
|
|
97
|
+
content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--value"),
|
|
98
|
+
content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--meta"),
|
|
99
|
+
content_tag(:span, "", class: "railsui-chart-skeleton__plot"),
|
|
100
|
+
content_tag(:span, "", class: "railsui-skeleton-bar railsui-skeleton-bar--footer")
|
|
101
|
+
])
|
|
102
|
+
end
|
|
95
103
|
end
|
|
96
104
|
end
|
|
97
105
|
|
|
98
106
|
private
|
|
99
107
|
|
|
108
|
+
def skeleton_alignment(align)
|
|
109
|
+
alignment = align.to_sym
|
|
110
|
+
return alignment if %i[left center right].include?(alignment)
|
|
111
|
+
|
|
112
|
+
raise ArgumentError, "Unsupported skeleton alignment: #{align}. Supported: left, center, right"
|
|
113
|
+
end
|
|
114
|
+
|
|
100
115
|
def chart_state(kind, title:, description:, height:)
|
|
101
116
|
content_tag(:div,
|
|
102
117
|
class: "railsui-chart-state railsui-chart-state--#{kind}",
|
|
@@ -149,6 +164,7 @@ module RailsuiCharts
|
|
|
149
164
|
columns = Array(series).map { |s| { name: s[:name] || "Value", data: s[:data] } }
|
|
150
165
|
categories = config.dig(:xaxis, :categories) || []
|
|
151
166
|
end
|
|
167
|
+
comparison_categories = config[:compare_categories] || []
|
|
152
168
|
|
|
153
169
|
rows = columns.first&.dig(:data)
|
|
154
170
|
return if rows.blank?
|
|
@@ -159,7 +175,7 @@ module RailsuiCharts
|
|
|
159
175
|
content_tag(:thead) do
|
|
160
176
|
content_tag(:tr) do
|
|
161
177
|
safe_join(
|
|
162
|
-
|
|
178
|
+
accessibility_category_headers(comparison_categories) +
|
|
163
179
|
columns.map { |column| content_tag(:th, column[:name]) }
|
|
164
180
|
)
|
|
165
181
|
end
|
|
@@ -169,7 +185,7 @@ module RailsuiCharts
|
|
|
169
185
|
rows.each_with_index.map do |_, index|
|
|
170
186
|
content_tag(:tr) do
|
|
171
187
|
safe_join(
|
|
172
|
-
|
|
188
|
+
accessibility_category_cells(categories, comparison_categories, index) +
|
|
173
189
|
columns.map { |column| content_tag(:td, cell_value(column[:data][index])) }
|
|
174
190
|
)
|
|
175
191
|
end
|
|
@@ -180,6 +196,18 @@ module RailsuiCharts
|
|
|
180
196
|
end
|
|
181
197
|
end
|
|
182
198
|
|
|
199
|
+
def accessibility_category_headers(comparison_categories)
|
|
200
|
+
headers = [content_tag(:th, "Category")]
|
|
201
|
+
headers << content_tag(:th, "Comparison category") if comparison_categories.present?
|
|
202
|
+
headers
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def accessibility_category_cells(categories, comparison_categories, index)
|
|
206
|
+
cells = [content_tag(:td, categories[index] || index + 1)]
|
|
207
|
+
cells << content_tag(:td, comparison_categories[index] || index + 1) if comparison_categories.present?
|
|
208
|
+
cells
|
|
209
|
+
end
|
|
210
|
+
|
|
183
211
|
def cell_value(value)
|
|
184
212
|
case value
|
|
185
213
|
when Array then value.join(", ")
|
data/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getrailsui/charts",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|