railsui_charts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,438 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import ApexCharts from "apexcharts"
3
+
4
+ export default class extends Controller {
5
+ static values = { options: Object }
6
+
7
+ connect() {
8
+ // Bind before the first render: the theme getter reads the media query, so
9
+ // rendering first paints every chart light on a dark OS.
10
+ this.bindThemeListeners()
11
+
12
+ // Apex measures the element as it renders. Anything hidden at connect — a
13
+ // closed dialog, a collapsed panel — measures zero and draws nothing, so
14
+ // there is no point building it yet. Whatever reveals it says so, and the
15
+ // chart lays itself out then.
16
+ this.refresh = this.refresh.bind(this)
17
+ this.element.addEventListener("railsui-chart:refresh", this.refresh)
18
+
19
+ this.watchViewport()
20
+ }
21
+
22
+ disconnect() {
23
+ this.unwatchViewport()
24
+ this.element.removeEventListener("railsui-chart:refresh", this.refresh)
25
+ this.destroy()
26
+ this.unbindThemeListeners()
27
+ }
28
+
29
+ // Building every chart at once is work the reader has not asked for, and a
30
+ // page carrying a dozen of them spends the first seconds laying out charts
31
+ // nobody is looking at. Each one waits until it is nearly on screen; the
32
+ // server reserves its height so nothing shifts when it arrives.
33
+ watchViewport() {
34
+ if (!("IntersectionObserver" in window)) {
35
+ if (this.measurable) this.render()
36
+ return
37
+ }
38
+
39
+ this.viewportObserver = new IntersectionObserver(
40
+ (entries) => {
41
+ if (!entries.some((entry) => entry.isIntersecting)) return
42
+
43
+ this.unwatchViewport()
44
+ if (this.measurable) this.render()
45
+ },
46
+ // Start early enough that it is drawn by the time it is scrolled to.
47
+ { rootMargin: "300px 0px" }
48
+ )
49
+
50
+ this.viewportObserver.observe(this.element)
51
+ }
52
+
53
+ unwatchViewport() {
54
+ this.viewportObserver?.disconnect()
55
+ this.viewportObserver = null
56
+ }
57
+
58
+ // An explicit reveal outranks the viewport check — a dialog opening means
59
+ // draw it now.
60
+ refresh() {
61
+ this.unwatchViewport()
62
+ if (this.measurable) this.render()
63
+ }
64
+
65
+ rerender() {
66
+ if (this.chart) this.render()
67
+ }
68
+
69
+ // Anything inside a `display: none` subtree — a closed <dialog>, a hidden
70
+ // tab panel — reports no client rects.
71
+ get measurable() {
72
+ return this.element.getClientRects().length > 0
73
+ }
74
+
75
+ render() {
76
+ this.destroy()
77
+ this.chart = new ApexCharts(this.element, this.resolvedOptions())
78
+ this.chart.render()
79
+ }
80
+
81
+ destroy() {
82
+ if (this.chart) {
83
+ this.chart.destroy()
84
+ this.chart = null
85
+ }
86
+ }
87
+
88
+ resolvedOptions() {
89
+ const options = this.resolveCssVariables({
90
+ ...this.optionsValue,
91
+ chart: {
92
+ ...(this.optionsValue.chart || {}),
93
+ animations: {
94
+ ...(this.optionsValue.chart?.animations || {}),
95
+ enabled: this.animationsEnabled
96
+ }
97
+ },
98
+ theme: {
99
+ ...this.optionsValue.theme,
100
+ // Last word: the server ships a static `light` default it cannot know
101
+ // better than, so spreading it after would pin every chart to light.
102
+ mode: this.darkMode ? "dark" : "light"
103
+ }
104
+ })
105
+
106
+ return this.applyTooltip(this.applyEdgeLabels(this.applyFormatters(options)))
107
+ }
108
+
109
+ // Apex's stock tooltip is a dark slab whatever the page is doing. This one is
110
+ // built from the same CSS variables as everything else, so it follows the
111
+ // theme, and it leads with what changed rather than with the date.
112
+ applyTooltip(options) {
113
+ // `tooltip_style: false` hands the tooltip back to Apex; passing your own
114
+ // `tooltip.custom` also wins.
115
+ if (options.tooltip_style === false) return options
116
+ if (options.tooltip?.custom || options.chart?.sparkline?.enabled) return options
117
+
118
+ const format = this.formatterFor(options.format || "number", options.currency)
119
+ // One formatter per series on a combo, so a currency row and a percentage
120
+ // row in the same tooltip each read in their own units.
121
+ const rowFormats = (options.series_formats || []).map((name) => this.formatterFor(name, options.currency))
122
+ const comparedDates = options.compare_categories || []
123
+ const upIsGood = options.trend_up_is_good !== false
124
+ const showDelta = options.tooltip_delta !== false
125
+ const headingMode = options.tooltip_heading
126
+
127
+ return {
128
+ ...options,
129
+ tooltip: {
130
+ ...(options.tooltip || {}),
131
+ custom: ({ series, dataPointIndex, w }) => {
132
+ const labels = w.globals.categoryLabels?.length ? w.globals.categoryLabels : w.globals.labels
133
+ const point = (index) => series[index]?.[dataPointIndex]
134
+ const comparing = series.length === 2 && comparedDates.length > 0
135
+
136
+ const rows = series.map((_, index) => ({
137
+ // In a comparison the two rows are the same metric at different
138
+ // dates, so the date identifies them. Otherwise the name does.
139
+ label: comparing
140
+ ? (index === 0 ? labels?.[dataPointIndex] : comparedDates[dataPointIndex])
141
+ : w.globals.seriesNames[index],
142
+ value: point(index),
143
+ color: w.globals.colors[index],
144
+ format: rowFormats[index] || null
145
+ }))
146
+
147
+ // Auto: a comparison leads with the metric, since the rows carry the
148
+ // dates. Anything else leads with the point being hovered.
149
+ const leadsWithSeries = headingMode ? headingMode === "series" : comparing
150
+ const heading = leadsWithSeries ? w.globals.seriesNames[0] : labels?.[dataPointIndex]
151
+ const delta = showDelta && comparing ? this.tooltipDelta(point(0), point(1), upIsGood) : null
152
+
153
+ return this.tooltipMarkup(heading, rows, delta, format)
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ tooltipDelta(current, previous, upIsGood) {
160
+ if (![current, previous].every((n) => typeof n === "number") || previous === 0) return null
161
+
162
+ const change = ((current - previous) / Math.abs(previous)) * 100
163
+ if (!isFinite(change)) return null
164
+
165
+ const rounded = Math.round(change * 100) / 100
166
+ return {
167
+ text: `${rounded > 0 ? "+" : ""}${rounded}%`,
168
+ tone: rounded === 0 ? "neutral" : (rounded > 0) === upIsGood ? "positive" : "negative"
169
+ }
170
+ }
171
+
172
+ tooltipMarkup(heading, rows, delta, format) {
173
+ const cells = rows
174
+ .filter((row) => row.value !== null && row.value !== undefined)
175
+ .map(
176
+ (row) => `
177
+ <tr>
178
+ <th scope="row">
179
+ <span class="railsui-chart-tooltip__key" style="background:${this.escape(row.color)}"></span>
180
+ ${this.escape(row.label)}
181
+ </th>
182
+ <td>${this.escape(this.formatRow(row, format))}</td>
183
+ </tr>`
184
+ )
185
+ .join("")
186
+
187
+ const badge = delta
188
+ ? `<span class="railsui-chart-tooltip__delta railsui-chart-tooltip__delta--${delta.tone}">${this.escape(delta.text)}</span>`
189
+ : ""
190
+
191
+ return `
192
+ <div class="railsui-chart-tooltip">
193
+ <div class="railsui-chart-tooltip__head">
194
+ <span class="railsui-chart-tooltip__title">${this.escape(heading)}</span>${badge}
195
+ </div>
196
+ <table class="railsui-chart-tooltip__rows">${cells}</table>
197
+ </div>`
198
+ }
199
+
200
+ // A row's own formatter when it has one, the chart's otherwise.
201
+ formatRow(row, fallback) {
202
+ const format = row.format || fallback
203
+ return format ? format(row.value) : row.value
204
+ }
205
+
206
+ // Series names and category labels come from application data.
207
+ escape(value) {
208
+ return String(value ?? "").replace(/[&<>"']/g, (char) => {
209
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]
210
+ })
211
+ }
212
+
213
+ // Stripe-style cards label only the first and last tick, so the axis reads as
214
+ // a range rather than a row of collided, rotated dates.
215
+ applyEdgeLabels(options) {
216
+ if (!options.edge_labels) return options
217
+
218
+ const categories = options.xaxis?.categories || []
219
+ const last = categories.length - 1
220
+
221
+ return {
222
+ ...options,
223
+ xaxis: {
224
+ ...(options.xaxis || {}),
225
+ labels: {
226
+ ...(options.xaxis?.labels || {}),
227
+ formatter: (value, _timestamp, opts) => {
228
+ const index = typeof opts?.i === "number" ? opts.i : categories.indexOf(value)
229
+ return index === 0 || index === last ? value : ""
230
+ }
231
+ }
232
+ },
233
+ // Apex reuses the axis formatter for the tooltip title, which would blank
234
+ // out every point between the two edges.
235
+ tooltip: {
236
+ ...(options.tooltip || {}),
237
+ x: {
238
+ ...(options.tooltip?.x || {}),
239
+ formatter: (value, opts) => categories[opts?.dataPointIndex] ?? value
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ applyFormatters(options) {
246
+ const formatter = this.formatterFor(options.format || "number", options.currency)
247
+ if (!formatter) return options
248
+
249
+ const withFormatter = (axis) => {
250
+ if (Array.isArray(axis)) return axis.map(withFormatter)
251
+
252
+ // An axis may name its own format. A combo measures money on one side
253
+ // and a percentage on the other, and one formatter across both dresses
254
+ // one of the two scales in the wrong units.
255
+ const own = axis?.format ? this.formatterFor(axis.format, options.currency) : null
256
+
257
+ return { ...(axis || {}), labels: { ...(axis?.labels || {}), formatter: own || formatter } }
258
+ }
259
+
260
+ // A horizontal bar puts its values along x and its categories up y, so
261
+ // formatting the y-axis there would dress the labels and leave the numbers
262
+ // bare.
263
+ if (options.plotOptions?.bar?.horizontal) {
264
+ // Unless it is a time axis. A timeline's x values are milliseconds, and a
265
+ // number formatter over the top turns every tick into "1,786,380,000,000"
266
+ // where a date belongs. Apex formats a datetime axis from the date
267
+ // itself, so the right move is to leave it alone.
268
+ const timeAxis = options.xaxis?.type === "datetime"
269
+
270
+ return {
271
+ ...options,
272
+ ...(timeAxis ? {} : { xaxis: withFormatter(options.xaxis) }),
273
+ tooltip: { ...(options.tooltip || {}), y: { ...(options.tooltip?.y || {}), formatter: formatter } }
274
+ }
275
+ }
276
+
277
+ const formatted = {
278
+ ...options,
279
+ yaxis: withFormatter(options.yaxis),
280
+ tooltip: {
281
+ ...(options.tooltip || {}),
282
+ y: {
283
+ ...(options.tooltip?.y || {}),
284
+ formatter: formatter
285
+ }
286
+ }
287
+ }
288
+
289
+ // Only when there is one. Apex reads `responsive` as a list, and writing
290
+ // the key back as undefined is not the same as leaving it out — it finds
291
+ // the key, walks it, and throws before anything is drawn. A sparkline
292
+ // never gets breakpoints, so every one of them hit this and rendered as
293
+ // an empty element with no error in the console.
294
+ if (Array.isArray(options.responsive)) {
295
+ // Breakpoint overrides replace the axis rather than merging into it, so
296
+ // an unformatted mobile axis is the default unless the formatter is
297
+ // planted in each one too.
298
+ formatted.responsive = options.responsive.map((entry) => ({
299
+ ...entry,
300
+ options: {
301
+ ...(entry.options || {}),
302
+ ...(entry.options?.yaxis ? { yaxis: withFormatter(entry.options.yaxis) } : {})
303
+ }
304
+ }))
305
+ }
306
+
307
+ return formatted
308
+ }
309
+
310
+ formatterFor(format, currency = "$") {
311
+ switch (format) {
312
+ case "currency":
313
+ return (value) => {
314
+ if (value === null || value === undefined || isNaN(value)) return value
315
+ return `${currency}${Number(value).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
316
+ }
317
+ case "percentage":
318
+ return (value) => {
319
+ if (value === null || value === undefined || isNaN(value)) return value
320
+ return `${Number(value).toFixed(1)}%`
321
+ }
322
+ case "human":
323
+ return (value) => {
324
+ if (value === null || value === undefined || isNaN(value)) return value
325
+ return this.humanFormat(Number(value))
326
+ }
327
+ case "short_currency":
328
+ return (value) => {
329
+ if (value === null || value === undefined || isNaN(value)) return value
330
+ return `${currency}${this.humanFormat(Number(value))}`
331
+ }
332
+ case "number":
333
+ // Computed series arrive as floats, so an unformatted axis renders
334
+ // "860.0000000000000". Delimited and trimmed by default.
335
+ return (value) => {
336
+ if (value === null || value === undefined || isNaN(value)) return value
337
+ return Number(value).toLocaleString("en-US", { maximumFractionDigits: 2 })
338
+ }
339
+ default:
340
+ return null
341
+ }
342
+ }
343
+
344
+ humanFormat(value) {
345
+ if (value === 0) return "0"
346
+
347
+ const suffixes = ["", "K", "M", "B", "T"]
348
+ const tier = Math.log10(Math.abs(value)) / 3 | 0
349
+ if (tier === 0) return `${Math.round(value * 100) / 100}`
350
+
351
+ const scaled = value / Math.pow(10, tier * 3)
352
+ // 2.0K reads worse than 2K; only keep the decimal when it carries meaning.
353
+ const rounded = Math.round(scaled * 10) / 10
354
+ return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}${suffixes[tier]}`
355
+ }
356
+
357
+ resolveCssVariables(value) {
358
+ if (typeof value === "string") {
359
+ return this.resolveCssVariable(value)
360
+ }
361
+
362
+ if (Array.isArray(value)) {
363
+ return value.map((item) => this.resolveCssVariables(item))
364
+ }
365
+
366
+ if (value !== null && typeof value === "object") {
367
+ return Object.entries(value).reduce((result, [key, val]) => {
368
+ result[key] = this.resolveCssVariables(val)
369
+ return result
370
+ }, {})
371
+ }
372
+
373
+ return value
374
+ }
375
+
376
+ resolveCssVariable(value) {
377
+ const match = value.match(/^var\((--[^,]+)(?:,\s*(.+))?\)$/)
378
+ if (!match) return value
379
+
380
+ const variableName = match[1]
381
+ const fallback = match[2]
382
+ // Read from the chart's own element so a scoped theme (a dark panel inside
383
+ // a light page) resolves against the surface it actually sits on.
384
+ const computed = getComputedStyle(this.element).getPropertyValue(variableName).trim()
385
+
386
+ return computed || fallback || value
387
+ }
388
+
389
+ bindThemeListeners() {
390
+ this.darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
391
+ this.motionMediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
392
+ // Only redraw what is already drawn. A chart still waiting for the
393
+ // viewport should keep waiting rather than be pulled forward by a theme
394
+ // change it is not on screen for.
395
+ this.themeHandler = () => this.rerender()
396
+
397
+ this.darkModeMediaQuery.addEventListener("change", this.themeHandler)
398
+ this.motionMediaQuery.addEventListener("change", this.themeHandler)
399
+
400
+ // Class- and attribute-based theme toggles never fire a media query event,
401
+ // so watch the root element for the swap too.
402
+ this.renderedDarkMode = this.darkMode
403
+ this.themeObserver = new MutationObserver(() => {
404
+ const dark = this.darkMode
405
+ if (dark === this.renderedDarkMode) return
406
+
407
+ this.renderedDarkMode = dark
408
+ this.rerender()
409
+ })
410
+ this.themeObserver.observe(document.documentElement, {
411
+ attributes: true,
412
+ attributeFilter: ["class", "data-theme"]
413
+ })
414
+ }
415
+
416
+ unbindThemeListeners() {
417
+ if (this.themeHandler) {
418
+ this.darkModeMediaQuery?.removeEventListener("change", this.themeHandler)
419
+ this.motionMediaQuery?.removeEventListener("change", this.themeHandler)
420
+ }
421
+ this.themeObserver?.disconnect()
422
+ }
423
+
424
+ get animationsEnabled() {
425
+ return !(this.motionMediaQuery && this.motionMediaQuery.matches)
426
+ }
427
+
428
+ get darkMode() {
429
+ const root = document.documentElement
430
+ const explicit = root.getAttribute("data-theme")
431
+
432
+ if (explicit === "dark") return true
433
+ if (explicit === "light") return false
434
+ if (root.classList.contains("dark")) return true
435
+
436
+ return this.darkModeMediaQuery ? this.darkModeMediaQuery.matches : false
437
+ }
438
+ }
@@ -0,0 +1,14 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Submits the filter row as soon as a control changes, so the whole view
4
+ // re-renders against one slice. The form is a plain GET, so every slice stays
5
+ // linkable and the page still works with this controller absent.
6
+ export default class extends Controller {
7
+ submit() {
8
+ if (this.element.requestSubmit) {
9
+ this.element.requestSubmit()
10
+ } else {
11
+ this.element.submit()
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,27 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Opens a metric card's expanded view: the same series with room to read it.
4
+ // A native <dialog> handles focus trapping, Escape, and inertness for us.
5
+ export default class extends Controller {
6
+ static targets = ["dialog"]
7
+
8
+ open() {
9
+ this.dialogTarget.showModal()
10
+
11
+ // The chart inside was laid out while the dialog was closed, which means
12
+ // it measured zero and drew nothing. Now that it has a size, tell it.
13
+ this.dialogTarget.querySelectorAll(".railsui-chart").forEach((chart) => {
14
+ chart.dispatchEvent(new CustomEvent("railsui-chart:refresh"))
15
+ })
16
+ }
17
+
18
+ close() {
19
+ this.dialogTarget.close()
20
+ }
21
+
22
+ // Clicking the backdrop lands on the dialog element itself; a click anywhere
23
+ // inside lands on a child.
24
+ closeOnBackdrop(event) {
25
+ if (event.target === this.dialogTarget) this.dialogTarget.close()
26
+ }
27
+ }