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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3fa00b0555667e87a913c0341ac9efcff529d54d4eb3b7e6a70fbe12167d1a1
4
- data.tar.gz: 47b6cc37a82c21c1ccbc1213a8876fa37fa8a2573ef6663629d4e1d09d4cb7fc
3
+ metadata.gz: '049e45e39a745fa833e9ba7835a09ecf960cb837b5d86c07c48d311201cddd6e'
4
+ data.tar.gz: 5b649ab130a83f43fa87490699fc5514eb00ee9eb92d5bdf6d059e31ac61f73e
5
5
  SHA512:
6
- metadata.gz: c5140c807d377faf06d02f1da3b1807d71bf023e3551ba74429ad5bc26aed89e8b19746570337b004080e881beced4cb76a7234c486bf1b28cd6b0810eed423c
7
- data.tar.gz: a38ce24f9ba774e6532df52c87e8c71c6260c2103e679702ecb85e50f06d37b40aa09a7d4b3efd1a6cf74eeaa199b48f00708cc76d90b6eaf1fb2a61789904c0
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, type: :donut %>
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 card skeleton stands in for the text that is coming and leaves the plot area empty a slab where the chart goes claims more about the shape of the data than a loading state can know. A standalone chart skeleton keeps faint gridlines, since it has nothing else to say it is a chart, and takes the shape of its `type:`.
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 membership adds `railsui_charts_pro`, which builds on this gem
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 || options.chart?.sparkline?.enabled) return options;
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: 0;
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
- /* Gridlines rather than a plain block, so a standalone chart placeholder still
200
- reads as a chart arriving. */
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
- width: 100%;
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
- /* A sheen sweeping across, rather than the whole block breathing. Pulsing
220
- opacity dims the gridlines too and reads as a broken element; a sweep reads
221
- as work in progress. */
222
- .railsui-chart-skeleton::after,
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 || options.chart?.sparkline?.enabled) return options
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 { |point| { x: point[:x], y: Array(point[:y]).map { |edge| range_edge(edge) } } }
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. Pass the
84
- # `type:` it will become so the placeholder is the shape being waited on.
85
- def railsui_chart_skeleton(height: nil, type: :line, label: "Loading chart")
86
- classes = ["railsui-chart-skeleton"]
87
- classes << "railsui-chart-skeleton--circular" if CIRCULAR_TYPES.include?(type.to_sym)
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(:span, "", class: classes.join(" "), aria: { hidden: true })
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
- [content_tag(:th, "Category")] +
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
- [content_tag(:td, categories[index] || index + 1)] +
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(", ")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsuiCharts
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getrailsui/charts",
3
- "version": "0.2.0",
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",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railsui_charts
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Leverenz