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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE.md +21 -0
- data/README.md +540 -0
- data/Rakefile +34 -0
- data/app/assets/stylesheets/railsui_charts.css +710 -0
- data/app/controllers/railsui_charts/demo_controller.rb +27 -0
- data/app/javascript/controllers/railsui_chart_controller.js +438 -0
- data/app/javascript/controllers/railsui_chart_filters_controller.js +14 -0
- data/app/javascript/controllers/railsui_metric_dialog_controller.js +27 -0
- data/app/views/railsui_charts/demo/index.html.erb +45 -0
- data/config/routes.rb +4 -0
- data/lib/generators/railsui_charts/install/install_generator.rb +52 -0
- data/lib/generators/railsui_charts/install/templates/railsui_chart_controller.js +438 -0
- data/lib/generators/railsui_charts/install/templates/railsui_chart_filters_controller.js +14 -0
- data/lib/generators/railsui_charts/install/templates/railsui_metric_dialog_controller.js +27 -0
- data/lib/railsui_charts/apex_options_builder.rb +1027 -0
- data/lib/railsui_charts/chart_helper.rb +195 -0
- data/lib/railsui_charts/configuration.rb +89 -0
- data/lib/railsui_charts/engine.rb +19 -0
- data/lib/railsui_charts/filter_helper.rb +90 -0
- data/lib/railsui_charts/filters.rb +130 -0
- data/lib/railsui_charts/interval.rb +110 -0
- data/lib/railsui_charts/metric_helper.rb +219 -0
- data/lib/railsui_charts/version.rb +5 -0
- data/lib/railsui_charts.rb +26 -0
- metadata +86 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsuiCharts
|
|
4
|
+
class DemoController < ::ActionController::Base
|
|
5
|
+
layout false
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@monthly_revenue = [
|
|
9
|
+
{ x: "Jan", y: 32_000 },
|
|
10
|
+
{ x: "Feb", y: 35_000 },
|
|
11
|
+
{ x: "Mar", y: 42_000 },
|
|
12
|
+
{ x: "Apr", y: 38_000 },
|
|
13
|
+
{ x: "May", y: 48_290 },
|
|
14
|
+
{ x: "Jun", y: 52_000 }
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
@daily_signups = [12, 19, 15, 25, 22, 30, 28]
|
|
18
|
+
@plan_distribution = [
|
|
19
|
+
{ x: "Starter", y: 120 },
|
|
20
|
+
{ x: "Pro", y: 85 },
|
|
21
|
+
{ x: "Enterprise", y: 32 }
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
@page_views = [120, 132, 101, 134, 190, 230, 210, 180, 250, 280]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>RailsUI Charts Demo</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<%= stylesheet_link_tag "tailwind" if defined?(stylesheet_link_tag) %>
|
|
7
|
+
<%= stylesheet_link_tag "railsui_charts" %>
|
|
8
|
+
</head>
|
|
9
|
+
<body class="bg-slate-50 dark:bg-slate-900 text-slate-800 dark:text-slate-100 p-8">
|
|
10
|
+
<div class="max-w-5xl mx-auto space-y-8">
|
|
11
|
+
<div>
|
|
12
|
+
<h1 class="text-3xl font-bold tracking-tight">RailsUI Charts Demo</h1>
|
|
13
|
+
<p class="text-slate-600 dark:text-slate-400 mt-2">Server-rendered, accessible charts for Rails.</p>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
17
|
+
<%= railsui_metric label: "Monthly revenue", value: 48_290, change: 12.4, format: :currency, history: @monthly_revenue.map { |d| d[:y] } %>
|
|
18
|
+
<%= railsui_metric label: "Daily signups", value: 28, change: -3.2, format: :number, history: @daily_signups %>
|
|
19
|
+
<%= railsui_metric label: "Page views", value: 1_827, change: 8.1, format: :human, history: @page_views %>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 shadow-sm">
|
|
23
|
+
<h2 class="text-lg font-semibold mb-4">Revenue over time</h2>
|
|
24
|
+
<%= railsui_chart @monthly_revenue, type: :area, label: "Revenue", height: 300 %>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
28
|
+
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 shadow-sm">
|
|
29
|
+
<h2 class="text-lg font-semibold mb-4">Plans</h2>
|
|
30
|
+
<%= railsui_chart @plan_distribution, type: :bar, label: "Customers", height: 250 %>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 shadow-sm">
|
|
34
|
+
<h2 class="text-lg font-semibold mb-4">Page view trend</h2>
|
|
35
|
+
<%= railsui_chart @page_views, type: :sparkline, height: 100 %>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<% if defined?(javascript_importmap_tags) %>
|
|
41
|
+
<%= javascript_importmap_tags %>
|
|
42
|
+
<% end %>
|
|
43
|
+
<%= javascript_include_tag "application" if defined?(javascript_include_tag) %>
|
|
44
|
+
</body>
|
|
45
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module RailsuiCharts
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
def add_css_import
|
|
9
|
+
application_css = Rails.root.join("app/assets/tailwind/application.css")
|
|
10
|
+
|
|
11
|
+
if File.exist?(application_css)
|
|
12
|
+
content = File.read(application_css)
|
|
13
|
+
import_statement = '@import "../../stylesheets/railsui_charts";'
|
|
14
|
+
|
|
15
|
+
unless content.include?(import_statement)
|
|
16
|
+
File.write(application_css, "#{content.strip}\n#{import_statement}\n")
|
|
17
|
+
say "✓ Added RailsUI Charts CSS import to app/assets/tailwind/application.css", :green
|
|
18
|
+
else
|
|
19
|
+
say "✓ RailsUI Charts CSS import already present", :green
|
|
20
|
+
end
|
|
21
|
+
else
|
|
22
|
+
say "⚠️ app/assets/tailwind/application.css not found. Add this import manually:", :yellow
|
|
23
|
+
say ' @import "../../stylesheets/railsui_charts";'
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
CONTROLLERS = %w[
|
|
28
|
+
railsui_chart_controller.js
|
|
29
|
+
railsui_chart_filters_controller.js
|
|
30
|
+
railsui_metric_dialog_controller.js
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
def copy_stimulus_controllers
|
|
34
|
+
CONTROLLERS.each do |controller|
|
|
35
|
+
copy_file controller, "app/javascript/controllers/#{controller}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def print_next_steps
|
|
40
|
+
say ""
|
|
41
|
+
say "RailsUI Charts installed.", :green
|
|
42
|
+
say ""
|
|
43
|
+
say "Next steps:", :cyan
|
|
44
|
+
say " 1. Add the ApexCharts dependency for your JS setup:", :cyan
|
|
45
|
+
say " Build mode: yarn add apexcharts", :cyan
|
|
46
|
+
say " No-build: pin \"apexcharts\", to: \"https://esm.sh/apexcharts@3.45.2\"", :cyan
|
|
47
|
+
say " 2. Use <%= railsui_chart data, type: :area %> in your views.", :cyan
|
|
48
|
+
say ""
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|