poetry-charts 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitattributes +6 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +94 -0
- data/THIRD_PARTY_NOTICES.md +39 -0
- data/app/assets/stylesheets/poetry-charts.css +117 -0
- data/app/components/poetry/charts/adapter_chart/component.html.erb +6 -0
- data/app/components/poetry/charts/adapter_chart/component.rb +99 -0
- data/app/components/poetry/charts/adapter_chart/preview.rb +37 -0
- data/app/components/poetry/charts/adapter_chart/style.rb +16 -0
- data/app/components/poetry/charts/area_chart/component.html.erb +75 -0
- data/app/components/poetry/charts/area_chart/component.rb +251 -0
- data/app/components/poetry/charts/area_chart/preview.rb +95 -0
- data/app/components/poetry/charts/area_chart/style.rb +29 -0
- data/app/components/poetry/charts/bar_chart/component.html.erb +95 -0
- data/app/components/poetry/charts/bar_chart/component.rb +318 -0
- data/app/components/poetry/charts/bar_chart/preview.rb +115 -0
- data/app/components/poetry/charts/bar_chart/style.rb +28 -0
- data/app/components/poetry/charts/bar_math.rb +95 -0
- data/app/components/poetry/charts/cartesian_family.rb +105 -0
- data/app/components/poetry/charts/chart_family.rb +58 -0
- data/app/components/poetry/charts/composed_chart/component.html.erb +95 -0
- data/app/components/poetry/charts/composed_chart/component.rb +309 -0
- data/app/components/poetry/charts/composed_chart/preview.rb +62 -0
- data/app/components/poetry/charts/composed_chart/style.rb +24 -0
- data/app/components/poetry/charts/container/component.html.erb +4 -0
- data/app/components/poetry/charts/container/component.rb +80 -0
- data/app/components/poetry/charts/container/preview.rb +45 -0
- data/app/components/poetry/charts/container/style.rb +22 -0
- data/app/components/poetry/charts/error_bars.rb +35 -0
- data/app/components/poetry/charts/legend_content/component.html.erb +10 -0
- data/app/components/poetry/charts/legend_content/component.rb +122 -0
- data/app/components/poetry/charts/legend_content/preview.rb +23 -0
- data/app/components/poetry/charts/legend_content/style.rb +26 -0
- data/app/components/poetry/charts/line_chart/component.html.erb +85 -0
- data/app/components/poetry/charts/line_chart/component.rb +252 -0
- data/app/components/poetry/charts/line_chart/preview.rb +115 -0
- data/app/components/poetry/charts/line_chart/style.rb +28 -0
- data/app/components/poetry/charts/live.rb +296 -0
- data/app/components/poetry/charts/motion.rb +98 -0
- data/app/components/poetry/charts/pie_chart/component.html.erb +36 -0
- data/app/components/poetry/charts/pie_chart/component.rb +256 -0
- data/app/components/poetry/charts/pie_chart/preview.rb +100 -0
- data/app/components/poetry/charts/pie_chart/style.rb +19 -0
- data/app/components/poetry/charts/polar_family.rb +95 -0
- data/app/components/poetry/charts/radar_chart/component.html.erb +73 -0
- data/app/components/poetry/charts/radar_chart/component.rb +329 -0
- data/app/components/poetry/charts/radar_chart/preview.rb +97 -0
- data/app/components/poetry/charts/radar_chart/style.rb +21 -0
- data/app/components/poetry/charts/radial_bar_chart/component.html.erb +65 -0
- data/app/components/poetry/charts/radial_bar_chart/component.rb +424 -0
- data/app/components/poetry/charts/radial_bar_chart/preview.rb +99 -0
- data/app/components/poetry/charts/radial_bar_chart/style.rb +27 -0
- data/app/components/poetry/charts/reference_marks.rb +156 -0
- data/app/components/poetry/charts/scatter_chart/component.html.erb +55 -0
- data/app/components/poetry/charts/scatter_chart/component.rb +396 -0
- data/app/components/poetry/charts/scatter_chart/preview.rb +93 -0
- data/app/components/poetry/charts/scatter_chart/style.rb +22 -0
- data/app/components/poetry/charts/tooltip_content/component.html.erb +19 -0
- data/app/components/poetry/charts/tooltip_content/component.rb +168 -0
- data/app/components/poetry/charts/tooltip_content/preview.rb +39 -0
- data/app/components/poetry/charts/tooltip_content/style.rb +31 -0
- data/app/components/poetry/charts/tooltip_layer/component.html.erb +6 -0
- data/app/components/poetry/charts/tooltip_layer/component.rb +61 -0
- data/app/components/poetry/charts/tooltip_layer/style.rb +13 -0
- data/app/components/poetry/charts/tooltip_wiring.rb +167 -0
- data/app/helpers/poetry/charts/components_helper.rb +125 -0
- data/app/javascript/poetry/charts/adapter_controller.js +75 -0
- data/app/javascript/poetry/charts/adapter_registry.js +52 -0
- data/app/javascript/poetry/charts/adapters/chartjs.js +86 -0
- data/app/javascript/poetry/charts/d3.js +14 -0
- data/app/javascript/poetry/charts/index.js +67 -0
- data/app/javascript/poetry/charts/live/renderer.js +456 -0
- data/app/javascript/poetry/charts/live_controller.js +195 -0
- data/app/javascript/poetry/charts/motion/flip.js +169 -0
- data/app/javascript/poetry/charts/motion/sector.js +62 -0
- data/app/javascript/poetry/charts/motion/tween.js +105 -0
- data/app/javascript/poetry/charts/motion_controller.js +230 -0
- data/app/javascript/poetry/charts/tooltip_controller.js +331 -0
- data/app/javascript/poetry/charts/window_controller.js +195 -0
- data/config/component_descriptions.yml +14 -0
- data/config/component_registry.yml +3055 -0
- data/config/controllers_manifest.json +100 -0
- data/config/importmap.rb +10 -0
- data/lib/poetry/charts/cartesian.rb +277 -0
- data/lib/poetry/charts/config.rb +149 -0
- data/lib/poetry/charts/engine.rb +76 -0
- data/lib/poetry/charts/geometry/area.rb +68 -0
- data/lib/poetry/charts/geometry/curve.rb +327 -0
- data/lib/poetry/charts/geometry/line.rb +64 -0
- data/lib/poetry/charts/geometry/nice_ticks.rb +220 -0
- data/lib/poetry/charts/geometry/path.rb +96 -0
- data/lib/poetry/charts/geometry/scale/band.rb +81 -0
- data/lib/poetry/charts/geometry/scale/linear.rb +127 -0
- data/lib/poetry/charts/geometry/stack.rb +129 -0
- data/lib/poetry/charts/geometry/ticks.rb +103 -0
- data/lib/poetry/charts/geometry.rb +66 -0
- data/lib/poetry/charts/polar.rb +179 -0
- data/lib/poetry/charts/spec.rb +108 -0
- data/lib/poetry/charts/theme_style.rb +53 -0
- data/lib/poetry/charts/version.rb +8 -0
- data/lib/poetry/charts.rb +114 -0
- data/lib/poetry-charts.rb +4 -0
- data/themes/default.css +146 -0
- data/themes/luma.css +139 -0
- data/themes/lyra.css +140 -0
- data/themes/maia.css +139 -0
- data/themes/mira.css +139 -0
- data/themes/nova.css +139 -0
- data/themes/rhea.css +139 -0
- data/themes/sera.css +140 -0
- data/themes/vega.css +144 -0
- data/vendor/d3-kernel/LICENSE-d3.txt +17 -0
- data/vendor/d3-kernel/LICENSE-decimal.js-light.txt +25 -0
- data/vendor/d3-kernel/LICENSE-recharts.txt +26 -0
- data/vendor/d3-kernel/README.md +30 -0
- data/vendor/d3-kernel/build.mjs +66 -0
- data/vendor/d3-kernel/entry.js +27 -0
- data/vendor/d3-kernel/recharts/state/cartesianAxisSlice.ts +6 -0
- data/vendor/d3-kernel/recharts/util/scale/getNiceTickValues.ts +310 -0
- data/vendor/d3-kernel/recharts/util/scale/util/arithmetic.ts +54 -0
- data/vendor/d3-kernel/recharts/util/types.ts +5 -0
- metadata +186 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import {
|
|
2
|
+
scaleLinear, scaleBand, scalePoint,
|
|
3
|
+
line as d3Line, area as d3Area, stack as d3Stack,
|
|
4
|
+
stackOffsetNone, stackOffsetExpand,
|
|
5
|
+
curveLinear, curveNatural, curveStep, curveStepBefore, curveStepAfter, curveMonotoneX,
|
|
6
|
+
getNiceTickValues,
|
|
7
|
+
} from "@poetry/charts/d3"
|
|
8
|
+
|
|
9
|
+
// The live cartesian renderer: {spec, frame} + data ->
|
|
10
|
+
// geometry via the vendored kernel, applied to the server-rendered SVG as
|
|
11
|
+
// IN-PLACE attribute updates - never innerHTML - so the tooltip's targets,
|
|
12
|
+
// focus state, and host listeners survive every tick.
|
|
13
|
+
//
|
|
14
|
+
// This is lib/poetry/charts/cartesian.rb (+ the bar component's slot and
|
|
15
|
+
// path math) transcribed conventions-and-formatting exact: the vendored
|
|
16
|
+
// kernel generated the Ruby port's oracle fixtures, nice ticks are the
|
|
17
|
+
// same getNiceTickValues the server ports, fnum mirrors Ruby's 2-decimal
|
|
18
|
+
// half-away-from-zero rounding with native JS stringification, and paths
|
|
19
|
+
// keep the kernel's default 3-digit output - so a kernel render of
|
|
20
|
+
// dataset X is byte-equal to the server rendering dataset X (proven by
|
|
21
|
+
// the live_fixtures parity suite).
|
|
22
|
+
//
|
|
23
|
+
// Scope (declared): the streaming trio (area / line / bar), dense data,
|
|
24
|
+
// no labels, no tick formatters (the Ruby side raises on both at render).
|
|
25
|
+
// Count changes (sliding-window warm-up) reconcile group children by
|
|
26
|
+
// cloning the last sibling - still attribute-channel: engine-owned
|
|
27
|
+
// subtrees outside the group are never touched.
|
|
28
|
+
|
|
29
|
+
const X_AXIS_HEIGHT = 30
|
|
30
|
+
const Y_AXIS_WIDTH = 60
|
|
31
|
+
|
|
32
|
+
const CURVES = {
|
|
33
|
+
linear: curveLinear,
|
|
34
|
+
natural: curveNatural,
|
|
35
|
+
step: curveStep,
|
|
36
|
+
step_before: curveStepBefore,
|
|
37
|
+
step_after: curveStepAfter,
|
|
38
|
+
monotone_x: curveMonotoneX,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Ruby fnum: 2-decimal rounding (half away from zero - Ruby Float#round),
|
|
42
|
+
// JS-native stringification (bare integers).
|
|
43
|
+
const fnum = (v) => {
|
|
44
|
+
const scaled = v * 100
|
|
45
|
+
return String((Math.sign(scaled) * Math.round(Math.abs(scaled))) / 100)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const round2 = (v) => (Math.sign(v) * Math.round(Math.abs(v) * 100)) / 100
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ActiveSupport number_to_delimited, byte-for-byte for numerics:
|
|
52
|
+
* integer part grouped with commas, decimal part verbatim, strings
|
|
53
|
+
* untouched.
|
|
54
|
+
*
|
|
55
|
+
* @param {*} value
|
|
56
|
+
* @returns {string | null}
|
|
57
|
+
*/
|
|
58
|
+
export function displayValue(value) {
|
|
59
|
+
if (value == null) return null
|
|
60
|
+
if (typeof value === "number") {
|
|
61
|
+
const [integer, fraction] = String(value).split(".")
|
|
62
|
+
const grouped = integer.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
|
63
|
+
return fraction ? `${grouped}.${fraction}` : grouped
|
|
64
|
+
}
|
|
65
|
+
return String(value)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// -- the pipeline (Cartesian, transcribed) ------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* {spec, frame} -> geometry: the window slice, the hidden-series
|
|
72
|
+
* filter, scales, stacks, nice ticks, and the per-series point accessor
|
|
73
|
+
* (the transcribed pipeline the header pins).
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} payload - { spec, frame }
|
|
76
|
+
* @returns {Object} the geometry bag (scales, ticks, points(entry), ...)
|
|
77
|
+
*/
|
|
78
|
+
export function computeCartesian(payload) {
|
|
79
|
+
const { spec, frame } = payload
|
|
80
|
+
// The window (brush/zoom): [start, end] inclusive indices slice
|
|
81
|
+
// the FULL data before anything computes.
|
|
82
|
+
const data = frame.window
|
|
83
|
+
? spec.data.slice(frame.window[0], frame.window[1] + 1)
|
|
84
|
+
: spec.data
|
|
85
|
+
// Hidden series (the legend toggle) leave the domain and the
|
|
86
|
+
// stacks entirely - the chart rescales on hide.
|
|
87
|
+
const hidden = new Set(frame.hidden ?? [])
|
|
88
|
+
const series = spec.series.filter((entry) => !hidden.has(entry.key))
|
|
89
|
+
const horizontal = frame.layout === "horizontal"
|
|
90
|
+
const categoryAxis = frame.categoryAxis
|
|
91
|
+
const margin = frame.margin
|
|
92
|
+
|
|
93
|
+
const reservedLeft = (horizontal && categoryAxis) || (!horizontal && frame.valueAxis)
|
|
94
|
+
const plotLeft = margin.left + (reservedLeft ? Y_AXIS_WIDTH : 0)
|
|
95
|
+
const plotRight = frame.width - margin.right
|
|
96
|
+
const plotTop = margin.top
|
|
97
|
+
const plotBottom = frame.height - margin.bottom - (!horizontal && categoryAxis ? X_AXIS_HEIGHT : 0)
|
|
98
|
+
|
|
99
|
+
const xKey = horizontal ? spec.axes?.y?.dataKey : spec.axes?.x?.dataKey
|
|
100
|
+
const categories = xKey ? data.map((row) => row[xKey]) : data.map((_, i) => i)
|
|
101
|
+
|
|
102
|
+
// Index-domain scales: numerically identical to d3 category domains,
|
|
103
|
+
// immune to duplicate labels (the Ruby ports position by index too).
|
|
104
|
+
const indexes = categories.map((_, i) => i)
|
|
105
|
+
const categoryRange = horizontal ? [plotTop, plotBottom] : [plotLeft, plotRight]
|
|
106
|
+
const band = frame.xScaleType === "band"
|
|
107
|
+
const xScale = (band ? scaleBand() : scalePoint()).domain(indexes).range(categoryRange)
|
|
108
|
+
const bandWidth = band ? xScale.bandwidth() : 0
|
|
109
|
+
const xPositions = indexes.map((i) => xScale(i))
|
|
110
|
+
const xCenters = band ? xPositions.map((x) => x + bandWidth / 2) : xPositions
|
|
111
|
+
|
|
112
|
+
const valueAt = (i, key) => {
|
|
113
|
+
const value = data[i]?.[key]
|
|
114
|
+
return value == null ? NaN : Number(value)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// d3 stacks per stack id: keys in series order within the stack.
|
|
118
|
+
const stackBands = {}
|
|
119
|
+
for (const entry of series) {
|
|
120
|
+
const id = entry.stack
|
|
121
|
+
if (id == null || stackBands[id]) continue
|
|
122
|
+
const keys = series.filter((s) => s.stack === id).map((s) => s.key)
|
|
123
|
+
const offset = frame.offset === "expand" ? stackOffsetExpand : stackOffsetNone
|
|
124
|
+
const stacked = d3Stack().keys(keys).offset(offset)(data)
|
|
125
|
+
stackBands[id] = Object.fromEntries(stacked.map((s) => [s.key, s.map((p) => [p[0], p[1]])]))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The raw numeric domain before nicing: the default [0, auto].
|
|
129
|
+
const values = []
|
|
130
|
+
for (const entry of series) {
|
|
131
|
+
if (entry.stack != null) {
|
|
132
|
+
for (const [lo, hi] of stackBands[entry.stack][entry.key]) {
|
|
133
|
+
if (!Number.isNaN(lo)) values.push(lo)
|
|
134
|
+
if (!Number.isNaN(hi)) values.push(hi)
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
for (let i = 0; i < data.length; i++) {
|
|
138
|
+
const value = valueAt(i, entry.key)
|
|
139
|
+
if (!Number.isNaN(value)) values.push(value)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const rawDomain = values.length ? [Math.min(0, Math.min(...values)), Math.max(...values)] : [0, 1]
|
|
144
|
+
const yTicks = getNiceTickValues(frame.offset === "expand" ? [0, 1] : rawDomain, frame.yTickCount, true)
|
|
145
|
+
const yDomain = [yTicks[0], yTicks[yTicks.length - 1]]
|
|
146
|
+
const yScale = scaleLinear()
|
|
147
|
+
.domain(yDomain)
|
|
148
|
+
.range(horizontal ? [plotLeft, plotRight] : [plotBottom, plotTop])
|
|
149
|
+
|
|
150
|
+
const lo = Math.min(yDomain[0], yDomain[1])
|
|
151
|
+
const hi = Math.max(yDomain[0], yDomain[1])
|
|
152
|
+
const baseline = yScale(Math.min(Math.max(0, lo), hi))
|
|
153
|
+
|
|
154
|
+
const pointFor = (index, key, basePx, topPx) => {
|
|
155
|
+
const value = valueAt(index, key)
|
|
156
|
+
return horizontal
|
|
157
|
+
? { y: xCenters[index], x0: basePx, x1: topPx, value }
|
|
158
|
+
: { x: xCenters[index], y0: basePx, y1: topPx, value }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const points = (entry) => {
|
|
162
|
+
if (entry.stack != null) {
|
|
163
|
+
return stackBands[entry.stack][entry.key].map(([lo1, hi1], i) =>
|
|
164
|
+
pointFor(i, entry.key, yScale(lo1), yScale(hi1))
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
return data.map((_, i) => pointFor(i, entry.key, baseline, yScale(valueAt(i, entry.key))))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
horizontal, hidden, rows: data, plotLeft, plotRight, plotTop, plotBottom,
|
|
172
|
+
categories, xPositions, xCenters, bandWidth,
|
|
173
|
+
yTicks, yScale, baseline, points, valueAt,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// -- DOM application ----------------------------------------------------
|
|
178
|
+
|
|
179
|
+
// Update in place; when a group's child count changes (sliding-window
|
|
180
|
+
// warm-up), clone the last sibling as the template.
|
|
181
|
+
function reconcile(nodes, count) {
|
|
182
|
+
const list = [...nodes]
|
|
183
|
+
while (list.length > count) list.pop().remove()
|
|
184
|
+
while (list.length < count && list.length > 0) {
|
|
185
|
+
const clone = list[list.length - 1].cloneNode(true)
|
|
186
|
+
list[list.length - 1].after(clone)
|
|
187
|
+
list.push(clone)
|
|
188
|
+
}
|
|
189
|
+
return list
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function seriesPathAttrs(type, geometry, entry) {
|
|
193
|
+
const pts = geometry.points(entry)
|
|
194
|
+
const defined = (p) => !Number.isNaN(p.value)
|
|
195
|
+
const curve = CURVES[entry.curve ?? "natural"] ?? curveNatural
|
|
196
|
+
|
|
197
|
+
if (type === "area") {
|
|
198
|
+
const fill = d3Area().x((p) => p.x).y0((p) => p.y0).y1((p) => p.y1).defined(defined).curve(curve)(pts)
|
|
199
|
+
const stroke = d3Line().x((p) => p.x).y((p) => p.y1).defined(defined).curve(curve)(pts)
|
|
200
|
+
return { fill, stroke }
|
|
201
|
+
}
|
|
202
|
+
const path = d3Line()
|
|
203
|
+
.x((p) => (geometry.horizontal ? p.x1 : p.x))
|
|
204
|
+
.y((p) => (geometry.horizontal ? p.y : p.y1))
|
|
205
|
+
.defined(defined)
|
|
206
|
+
.curve(CURVES[entry.curve ?? "linear"] ?? curveLinear)(pts)
|
|
207
|
+
return { path }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The bar component's combineAllBarPositions port: 10% band trim, 4px
|
|
211
|
+
// group gap, size js_round-ed past 1px.
|
|
212
|
+
function barSlots(spec, frame, bandWidth) {
|
|
213
|
+
const groups = [...new Set(spec.series.map((s) => s.stack ?? s.key))]
|
|
214
|
+
const trim = percentValue(frame.barCategoryGap, bandWidth)
|
|
215
|
+
let gap = Number(frame.barGap)
|
|
216
|
+
if (bandWidth - 2 * trim - (groups.length - 1) * gap <= 0) gap = 0
|
|
217
|
+
let size = (bandWidth - 2 * trim - (groups.length - 1) * gap) / groups.length
|
|
218
|
+
if (size > 1) size = Math.round(size)
|
|
219
|
+
return Object.fromEntries(groups.map((group, i) => [group, { offset: trim + (size + gap) * i, size }]))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function percentValue(value, total) {
|
|
223
|
+
const text = String(value)
|
|
224
|
+
return text.endsWith("%") ? (total * parseFloat(text)) / 100 : parseFloat(text)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// bar_path, per-corner radii clamped to half the rect.
|
|
228
|
+
function barPath(radius, cell) {
|
|
229
|
+
const radii = Array.isArray(radius) ? radius.map(Number) : Array(4).fill(Number(radius))
|
|
230
|
+
const max = Math.min(cell.width / 2, cell.height / 2)
|
|
231
|
+
const [tl, tr, br, bl] = radii.map((r) => Math.min(Math.max(r, 0), max))
|
|
232
|
+
const f = fnum
|
|
233
|
+
let d = `M${f(cell.x)},${f(cell.y + tl)}`
|
|
234
|
+
if (tl > 0) d += `A${f(tl)},${f(tl)},0,0,1,${f(cell.x + tl)},${f(cell.y)}`
|
|
235
|
+
d += `L${f(cell.x + cell.width - tr)},${f(cell.y)}`
|
|
236
|
+
if (tr > 0) d += `A${f(tr)},${f(tr)},0,0,1,${f(cell.x + cell.width)},${f(cell.y + tr)}`
|
|
237
|
+
d += `L${f(cell.x + cell.width)},${f(cell.y + cell.height - br)}`
|
|
238
|
+
if (br > 0) d += `A${f(br)},${f(br)},0,0,1,${f(cell.x + cell.width - br)},${f(cell.y + cell.height)}`
|
|
239
|
+
d += `L${f(cell.x + bl)},${f(cell.y + cell.height)}`
|
|
240
|
+
if (bl > 0) d += `A${f(bl)},${f(bl)},0,0,1,${f(cell.x)},${f(cell.y + cell.height - bl)}`
|
|
241
|
+
return `${d}Z`
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function barCells(geometry, entry, slot) {
|
|
245
|
+
const pts = geometry.points(entry)
|
|
246
|
+
const cells = []
|
|
247
|
+
pts.forEach((p, i) => {
|
|
248
|
+
if (Number.isNaN(p.value)) return
|
|
249
|
+
if (geometry.horizontal) {
|
|
250
|
+
const left = Math.min(p.x0, p.x1)
|
|
251
|
+
cells.push({ index: i, value: p.value, x: left, y: geometry.xPositions[i] + slot.offset,
|
|
252
|
+
width: Math.abs(p.x1 - p.x0), height: slot.size })
|
|
253
|
+
} else {
|
|
254
|
+
const top = Math.min(p.y0, p.y1)
|
|
255
|
+
cells.push({ index: i, value: p.value, x: geometry.xPositions[i] + slot.offset, y: top,
|
|
256
|
+
width: slot.size, height: Math.abs(p.y1 - p.y0) })
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
return cells
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Applies a payload (whose spec.data is the CURRENT data) to the frame
|
|
264
|
+
* element (the div wrapping svg + chrome + scripts) as in-place
|
|
265
|
+
* attribute updates.
|
|
266
|
+
*
|
|
267
|
+
* @param {Element} frame
|
|
268
|
+
* @param {Object} payload - { spec, frame }
|
|
269
|
+
* @returns {Object} the geometry, so callers (the live controller) can
|
|
270
|
+
* chain
|
|
271
|
+
*/
|
|
272
|
+
export function applyCartesian(frame, payload) {
|
|
273
|
+
const svg = frame.querySelector('[data-slot="chart-svg"]')
|
|
274
|
+
const geometry = computeCartesian(payload)
|
|
275
|
+
const { spec } = payload
|
|
276
|
+
const type = spec.type
|
|
277
|
+
|
|
278
|
+
for (const entry of spec.series) {
|
|
279
|
+
if (geometry.hidden.has(entry.key)) {
|
|
280
|
+
setSeriesVisibility(svg, entry.key, false)
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
setSeriesVisibility(svg, entry.key, true)
|
|
284
|
+
if (type === "area") {
|
|
285
|
+
const { fill, stroke } = seriesPathAttrs(type, geometry, entry)
|
|
286
|
+
svg.querySelector(`path[data-slot="chart-area"][data-key="${entry.key}"]`)?.setAttribute("d", fill)
|
|
287
|
+
svg.querySelector(`path[data-slot="chart-area-stroke"][data-key="${entry.key}"]`)?.setAttribute("d", stroke)
|
|
288
|
+
} else if (type === "line") {
|
|
289
|
+
const { path } = seriesPathAttrs(type, geometry, entry)
|
|
290
|
+
svg.querySelector(`path[data-slot="chart-line"][data-key="${entry.key}"]`)?.setAttribute("d", path)
|
|
291
|
+
applyDots(svg, geometry, entry)
|
|
292
|
+
} else if (type === "bar") {
|
|
293
|
+
applyBars(svg, geometry, payload, entry)
|
|
294
|
+
}
|
|
295
|
+
applyActiveDots(svg, geometry, entry)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
applyGrid(svg, geometry)
|
|
299
|
+
applyAxes(svg, geometry, payload)
|
|
300
|
+
applyCoordinates(frame, geometry, payload)
|
|
301
|
+
return geometry
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// A hidden series' marks (paths, dot groups, bar groups, active dots)
|
|
305
|
+
// toggle display - attribute-channel, the DOM stays intact for the
|
|
306
|
+
// toggle back.
|
|
307
|
+
function setSeriesVisibility(svg, key, visible) {
|
|
308
|
+
const selector = [
|
|
309
|
+
`path[data-slot="chart-area"][data-key="${key}"]`,
|
|
310
|
+
`path[data-slot="chart-area-stroke"][data-key="${key}"]`,
|
|
311
|
+
`path[data-slot="chart-line"][data-key="${key}"]`,
|
|
312
|
+
`g[data-slot="chart-dots"][data-key="${key}"]`,
|
|
313
|
+
`g[data-slot="chart-bar-series"][data-key="${key}"]`,
|
|
314
|
+
`circle[data-slot="chart-active-dot"][data-key="${key}"]`,
|
|
315
|
+
].join(", ")
|
|
316
|
+
for (const el of svg.querySelectorAll(selector)) {
|
|
317
|
+
if (visible) el.style.removeProperty("display")
|
|
318
|
+
else el.style.setProperty("display", "none")
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function applyDots(svg, geometry, entry) {
|
|
323
|
+
const group = svg.querySelector(`g[data-slot="chart-dots"][data-key="${entry.key}"]`)
|
|
324
|
+
if (!group) return
|
|
325
|
+
const markers = geometry.points(entry).filter((p) => !Number.isNaN(p.value))
|
|
326
|
+
const dots = reconcile(group.querySelectorAll('[data-slot="chart-dot"]'), markers.length)
|
|
327
|
+
dots.forEach((dot, i) => {
|
|
328
|
+
dot.setAttribute("cx", fnum(geometry.horizontal ? markers[i].x1 : markers[i].x))
|
|
329
|
+
dot.setAttribute("cy", fnum(geometry.horizontal ? markers[i].y : markers[i].y1))
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function applyActiveDots(svg, geometry, entry) {
|
|
334
|
+
const dots = svg.querySelectorAll(`circle[data-slot="chart-active-dot"][data-key="${entry.key}"]`)
|
|
335
|
+
if (!dots.length) return
|
|
336
|
+
const markers = geometry.points(entry)
|
|
337
|
+
.map((p, i) => ({ ...p, index: i }))
|
|
338
|
+
.filter((p) => !Number.isNaN(p.value))
|
|
339
|
+
const group = dots[0].parentNode
|
|
340
|
+
const list = reconcile(dots, markers.length)
|
|
341
|
+
list.forEach((dot, i) => {
|
|
342
|
+
dot.setAttribute("data-index", String(markers[i].index))
|
|
343
|
+
dot.setAttribute("cx", fnum(geometry.horizontal ? markers[i].x1 : markers[i].x))
|
|
344
|
+
dot.setAttribute("cy", fnum(geometry.horizontal ? markers[i].y : markers[i].y1))
|
|
345
|
+
})
|
|
346
|
+
group.append(...list) // keep per-key runs contiguous after cloning
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function applyBars(svg, geometry, payload, entry) {
|
|
350
|
+
const group = svg.querySelector(`g[data-slot="chart-bar-series"][data-key="${entry.key}"]`)
|
|
351
|
+
if (!group) return
|
|
352
|
+
const slots = barSlots(payload.spec, payload.frame, geometry.bandWidth)
|
|
353
|
+
const slot = slots[entry.stack ?? entry.key]
|
|
354
|
+
const radius = payload.frame.series?.[entry.key]?.radius ?? 0
|
|
355
|
+
const cells = barCells(geometry, entry, slot)
|
|
356
|
+
const bars = reconcile(group.querySelectorAll('[data-slot="chart-bar"]'), cells.length)
|
|
357
|
+
bars.forEach((bar, i) => {
|
|
358
|
+
bar.setAttribute("data-index", String(cells[i].index))
|
|
359
|
+
bar.setAttribute("d", barPath(radius, cells[i]))
|
|
360
|
+
if (bar.hasAttribute("data-motion-origin")) {
|
|
361
|
+
const negative = cells[i].value < 0
|
|
362
|
+
bar.setAttribute("data-motion-origin",
|
|
363
|
+
geometry.horizontal ? (negative ? "right" : "left") : (negative ? "top" : "bottom"))
|
|
364
|
+
}
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function applyGrid(svg, geometry) {
|
|
369
|
+
const grid = svg.querySelector('g[data-slot="chart-grid"]')
|
|
370
|
+
if (!grid) return
|
|
371
|
+
const lines = [...grid.querySelectorAll("line")]
|
|
372
|
+
const horizontalLines = lines.filter((l) => l.getAttribute("y1") === l.getAttribute("y2"))
|
|
373
|
+
const verticalLines = lines.filter((l) => l.getAttribute("y1") !== l.getAttribute("y2"))
|
|
374
|
+
|
|
375
|
+
// Vertical layout: horizontal rules ride y ticks, vertical ones ride
|
|
376
|
+
// category centers; the horizontal layout swaps the sources.
|
|
377
|
+
const hSource = geometry.horizontal ? geometry.xCenters : geometry.yTicks.map((t) => geometry.yScale(t))
|
|
378
|
+
const vSource = geometry.horizontal ? geometry.yTicks.map((t) => geometry.yScale(t)) : geometry.xCenters
|
|
379
|
+
|
|
380
|
+
if (horizontalLines.length) {
|
|
381
|
+
reconcile(horizontalLines, hSource.length).forEach((el, i) => {
|
|
382
|
+
el.setAttribute("x1", fnum(geometry.plotLeft))
|
|
383
|
+
el.setAttribute("x2", fnum(geometry.plotRight))
|
|
384
|
+
el.setAttribute("y1", fnum(hSource[i]))
|
|
385
|
+
el.setAttribute("y2", fnum(hSource[i]))
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
if (verticalLines.length) {
|
|
389
|
+
reconcile(verticalLines, vSource.length).forEach((el, i) => {
|
|
390
|
+
el.setAttribute("x1", fnum(vSource[i]))
|
|
391
|
+
el.setAttribute("x2", fnum(vSource[i]))
|
|
392
|
+
el.setAttribute("y1", fnum(geometry.plotTop))
|
|
393
|
+
el.setAttribute("y2", fnum(geometry.plotBottom))
|
|
394
|
+
})
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function applyAxes(svg, geometry, payload) {
|
|
399
|
+
const { frame } = payload
|
|
400
|
+
const xAxis = svg.querySelector('g[data-slot="chart-x-axis"]')
|
|
401
|
+
if (xAxis) {
|
|
402
|
+
reconcile(xAxis.querySelectorAll("text"), geometry.categories.length).forEach((text, i) => {
|
|
403
|
+
text.setAttribute("x", fnum(geometry.xCenters[i]))
|
|
404
|
+
text.setAttribute("y", fnum(geometry.plotBottom + (frame.xTickMargin ?? 8)))
|
|
405
|
+
text.textContent = String(geometry.categories[i])
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const yAxis = svg.querySelector('g[data-slot="chart-y-axis"]')
|
|
410
|
+
if (yAxis) {
|
|
411
|
+
if (geometry.horizontal) {
|
|
412
|
+
// The category strip down the left (horizontal bars).
|
|
413
|
+
reconcile(yAxis.querySelectorAll("text"), geometry.categories.length).forEach((text, i) => {
|
|
414
|
+
text.setAttribute("x", fnum(geometry.plotLeft - (frame.yTickMargin ?? 8)))
|
|
415
|
+
text.setAttribute("y", fnum(geometry.xCenters[i]))
|
|
416
|
+
text.textContent = String(geometry.categories[i])
|
|
417
|
+
})
|
|
418
|
+
} else {
|
|
419
|
+
reconcile(yAxis.querySelectorAll("text"), geometry.yTicks.length).forEach((text, i) => {
|
|
420
|
+
text.setAttribute("x", fnum(geometry.plotLeft - (frame.yTickMargin ?? 8)))
|
|
421
|
+
text.setAttribute("y", fnum(geometry.yScale(geometry.yTicks[i])))
|
|
422
|
+
text.textContent = String(geometry.yTicks[i])
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Regenerate the tooltip's embedded coordinates (Cartesian#coordinates,
|
|
429
|
+
// transcribed) - the DOM always holds current state; the live controller
|
|
430
|
+
// dispatches the refresh that re-parses it.
|
|
431
|
+
function applyCoordinates(frame, geometry, payload) {
|
|
432
|
+
const script = frame.querySelector('script[data-slot="chart-coordinates"]')
|
|
433
|
+
if (!script) return
|
|
434
|
+
const { spec } = payload
|
|
435
|
+
const topKey = geometry.horizontal ? "x1" : "y1"
|
|
436
|
+
// Hidden series drop off the wire entirely - their tooltip rows hide
|
|
437
|
+
// (a row with no values entry never shows).
|
|
438
|
+
const visible = spec.series.filter((entry) => !geometry.hidden.has(entry.key))
|
|
439
|
+
const coordinates = {
|
|
440
|
+
layout: payload.frame.layout,
|
|
441
|
+
categories: geometry.categories,
|
|
442
|
+
[geometry.horizontal ? "y" : "x"]: geometry.xCenters.map(round2),
|
|
443
|
+
series: Object.fromEntries(visible.map((entry) => [
|
|
444
|
+
entry.key,
|
|
445
|
+
geometry.points(entry).map((p) => (Number.isNaN(p.value) ? null : round2(p[topKey]))),
|
|
446
|
+
])),
|
|
447
|
+
values: Object.fromEntries(visible.map((entry) => [
|
|
448
|
+
entry.key,
|
|
449
|
+
geometry.rows.map((row) => displayValue(row[entry.key])),
|
|
450
|
+
])),
|
|
451
|
+
}
|
|
452
|
+
if (payload.frame.xScaleType === "band") {
|
|
453
|
+
coordinates.band = { x: geometry.xPositions.map(round2), width: round2(geometry.bandWidth) }
|
|
454
|
+
}
|
|
455
|
+
script.textContent = JSON.stringify(coordinates)
|
|
456
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { applyCartesian } from "@poetry/charts/live/renderer"
|
|
3
|
+
import { captureGeometry, matchJobs, applyJobs, finishJobs } from "@poetry/charts/motion/flip"
|
|
4
|
+
import { tween } from "@poetry/charts/motion/tween"
|
|
5
|
+
|
|
6
|
+
// The live channel: a chart rendered with live: true carries
|
|
7
|
+
// its {spec, frame} payload in the DOM; this controller re-renders
|
|
8
|
+
// client-side (the kernel renderer, in-place attribute updates) whenever
|
|
9
|
+
// new data arrives - no server round trip. Two channels feed one path:
|
|
10
|
+
//
|
|
11
|
+
// 1. Replace the payload script's JSON - exactly what a
|
|
12
|
+
// turbo_stream.replace/update does. A MutationObserver picks it up.
|
|
13
|
+
// 2. The JS/event API: dispatch `poetry-chart:update` with
|
|
14
|
+
// { detail: { data } } on the frame, or call update(data) on the
|
|
15
|
+
// controller instance.
|
|
16
|
+
//
|
|
17
|
+
// Updates animate through the shared FLIP machinery (old geometry ->
|
|
18
|
+
// new, the same cross-render morph); a structure change (window growth, series
|
|
19
|
+
// change) snaps instead of morphing. The tooltip's
|
|
20
|
+
// embedded coordinates are regenerated by the renderer and a
|
|
21
|
+
// live:updated dispatch tells the tooltip controller to re-read them,
|
|
22
|
+
// so it serves fresh values mid-stream. prefers-reduced-motion snaps
|
|
23
|
+
// every update.
|
|
24
|
+
export default class ChartLiveController extends Controller {
|
|
25
|
+
// The events this controller dispatches (manifest surface;
|
|
26
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
27
|
+
static events = ["poetry--charts--live:settled", "poetry--charts--live:updated"]
|
|
28
|
+
|
|
29
|
+
static targets = ["payload"]
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Finds the SVG and starts the payload observer (the
|
|
33
|
+
* script-replacement channel).
|
|
34
|
+
*/
|
|
35
|
+
connect() {
|
|
36
|
+
this.svg = this.element.querySelector('[data-slot="chart-svg"]')
|
|
37
|
+
if (!this.svg || !this.hasPayloadTarget) return
|
|
38
|
+
|
|
39
|
+
this.observer = new MutationObserver((records) => this.#payloadChanged(records))
|
|
40
|
+
this.observer.observe(this.element, { subtree: true, childList: true, characterData: true })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Stops the observer and abandons any in-flight tween. */
|
|
44
|
+
disconnect() {
|
|
45
|
+
this.observer?.disconnect()
|
|
46
|
+
this.observer = null
|
|
47
|
+
this.cancel?.()
|
|
48
|
+
this.cancel = null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The event channel's action: a poetry-chart:update event's
|
|
53
|
+
* detail.data re-renders.
|
|
54
|
+
*
|
|
55
|
+
* @param {CustomEvent} event
|
|
56
|
+
*/
|
|
57
|
+
receive(event) {
|
|
58
|
+
this.update(event.detail.data)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Applies a window: [start, end] inclusive indices into the FULL
|
|
63
|
+
* data; the renderer slices before computing. Instant renders - a
|
|
64
|
+
* drag should track the pointer, not tween behind it.
|
|
65
|
+
*
|
|
66
|
+
* @param {[number, number]} window
|
|
67
|
+
*/
|
|
68
|
+
setWindow(window) {
|
|
69
|
+
const payload = this.#payload()
|
|
70
|
+
payload.frame.window = window
|
|
71
|
+
this.writing = true
|
|
72
|
+
try {
|
|
73
|
+
this.payloadTarget.textContent = JSON.stringify(payload)
|
|
74
|
+
} finally {
|
|
75
|
+
queueMicrotask(() => { this.writing = false })
|
|
76
|
+
}
|
|
77
|
+
this.#render(payload, { animate: false })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The interactive legend's click action (the series key rides an
|
|
82
|
+
* action param): hidden series leave the domain (the chart rescales
|
|
83
|
+
* on hide), their marks hide, their legend item dims.
|
|
84
|
+
*
|
|
85
|
+
* @param {Event} event - params.key names the series
|
|
86
|
+
*/
|
|
87
|
+
toggleSeries(event) {
|
|
88
|
+
const key = event.params.key
|
|
89
|
+
if (!key) return
|
|
90
|
+
this.hiddenKeys ??= new Set()
|
|
91
|
+
if (this.hiddenKeys.has(key)) this.hiddenKeys.delete(key)
|
|
92
|
+
else this.hiddenKeys.add(key)
|
|
93
|
+
|
|
94
|
+
for (const item of this.element.querySelectorAll('[data-slot="chart-legend-item"][data-key]')) {
|
|
95
|
+
item.toggleAttribute("data-hidden", this.hiddenKeys.has(item.dataset.key))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const payload = this.#payload()
|
|
99
|
+
payload.frame.hidden = [...this.hiddenKeys]
|
|
100
|
+
this.writing = true
|
|
101
|
+
try {
|
|
102
|
+
this.payloadTarget.textContent = JSON.stringify(payload)
|
|
103
|
+
} finally {
|
|
104
|
+
queueMicrotask(() => { this.writing = false })
|
|
105
|
+
}
|
|
106
|
+
this.#render(payload)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The JS API: hosts hand over the new data rows; the payload script
|
|
111
|
+
* is rewritten first (the DOM always holds current state), then
|
|
112
|
+
* rendered.
|
|
113
|
+
*
|
|
114
|
+
* @param {Array<Object>} data - the full replacement rows
|
|
115
|
+
*/
|
|
116
|
+
update(data) {
|
|
117
|
+
const payload = this.#payload()
|
|
118
|
+
payload.spec.data = data
|
|
119
|
+
this.writing = true
|
|
120
|
+
try {
|
|
121
|
+
this.payloadTarget.textContent = JSON.stringify(payload)
|
|
122
|
+
} finally {
|
|
123
|
+
queueMicrotask(() => { this.writing = false })
|
|
124
|
+
}
|
|
125
|
+
this.#render(payload)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// -- internals -------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
// The script-replacement channel: react only to mutations touching the
|
|
131
|
+
// payload script (the renderer's own coordinates rewrite passes by).
|
|
132
|
+
#payloadChanged(records) {
|
|
133
|
+
if (this.writing) return
|
|
134
|
+
const touchesPayload = records.some((record) => {
|
|
135
|
+
const node = record.target.nodeType === Node.TEXT_NODE ? record.target.parentNode : record.target
|
|
136
|
+
if (node?.matches?.('[data-slot="chart-live-payload"]')) return true
|
|
137
|
+
return [...record.addedNodes].some((added) => added.matches?.('[data-slot="chart-live-payload"]'))
|
|
138
|
+
})
|
|
139
|
+
if (touchesPayload) this.#render(this.#payload())
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#payload() {
|
|
143
|
+
return JSON.parse(this.payloadTarget.textContent)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#render(payload, { animate = true } = {}) {
|
|
147
|
+
this.cancel?.()
|
|
148
|
+
const before = captureGeometry(this.svg)
|
|
149
|
+
applyCartesian(this.element, payload)
|
|
150
|
+
this.dispatch("updated") // the tooltip re-reads the fresh coordinates
|
|
151
|
+
|
|
152
|
+
if (!animate || this.#reducedMotion()) {
|
|
153
|
+
this.#settle()
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
const jobs = matchJobs(this.svg, before)
|
|
157
|
+
if (!jobs) {
|
|
158
|
+
// Structure changed (window growth, gaps appearing) - snap;
|
|
159
|
+
// added/removed data never morphs.
|
|
160
|
+
this.#settle()
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.svg.setAttribute("data-motion", "morph")
|
|
165
|
+
applyJobs(jobs, 0) // FLIP: start from the previous geometry
|
|
166
|
+
this.cancel = tween({
|
|
167
|
+
duration: this.#number("--poetry-motion-duration", 1500),
|
|
168
|
+
easing: this.#styleVar("--poetry-motion-easing") ?? "ease",
|
|
169
|
+
onFrame: (eased) => applyJobs(jobs, eased),
|
|
170
|
+
onFinish: () => {
|
|
171
|
+
finishJobs(jobs)
|
|
172
|
+
this.#settle()
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#settle() {
|
|
178
|
+
this.svg.setAttribute("data-motion", "settled")
|
|
179
|
+
this.dispatch("settled")
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#styleVar(name) {
|
|
183
|
+
const match = (this.svg.getAttribute("style") ?? "").match(new RegExp(`${name}:\\s*([^;]+)`))
|
|
184
|
+
return match ? match[1].trim() : null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#number(name, fallback) {
|
|
188
|
+
const value = parseFloat(this.#styleVar(name))
|
|
189
|
+
return Number.isFinite(value) ? value : fallback
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#reducedMotion() {
|
|
193
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
194
|
+
}
|
|
195
|
+
}
|