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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/.gitattributes +6 -0
  3. data/CHANGELOG.md +3 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +94 -0
  6. data/THIRD_PARTY_NOTICES.md +39 -0
  7. data/app/assets/stylesheets/poetry-charts.css +117 -0
  8. data/app/components/poetry/charts/adapter_chart/component.html.erb +6 -0
  9. data/app/components/poetry/charts/adapter_chart/component.rb +99 -0
  10. data/app/components/poetry/charts/adapter_chart/preview.rb +37 -0
  11. data/app/components/poetry/charts/adapter_chart/style.rb +16 -0
  12. data/app/components/poetry/charts/area_chart/component.html.erb +75 -0
  13. data/app/components/poetry/charts/area_chart/component.rb +251 -0
  14. data/app/components/poetry/charts/area_chart/preview.rb +95 -0
  15. data/app/components/poetry/charts/area_chart/style.rb +29 -0
  16. data/app/components/poetry/charts/bar_chart/component.html.erb +95 -0
  17. data/app/components/poetry/charts/bar_chart/component.rb +318 -0
  18. data/app/components/poetry/charts/bar_chart/preview.rb +115 -0
  19. data/app/components/poetry/charts/bar_chart/style.rb +28 -0
  20. data/app/components/poetry/charts/bar_math.rb +95 -0
  21. data/app/components/poetry/charts/cartesian_family.rb +105 -0
  22. data/app/components/poetry/charts/chart_family.rb +58 -0
  23. data/app/components/poetry/charts/composed_chart/component.html.erb +95 -0
  24. data/app/components/poetry/charts/composed_chart/component.rb +309 -0
  25. data/app/components/poetry/charts/composed_chart/preview.rb +62 -0
  26. data/app/components/poetry/charts/composed_chart/style.rb +24 -0
  27. data/app/components/poetry/charts/container/component.html.erb +4 -0
  28. data/app/components/poetry/charts/container/component.rb +80 -0
  29. data/app/components/poetry/charts/container/preview.rb +45 -0
  30. data/app/components/poetry/charts/container/style.rb +22 -0
  31. data/app/components/poetry/charts/error_bars.rb +35 -0
  32. data/app/components/poetry/charts/legend_content/component.html.erb +10 -0
  33. data/app/components/poetry/charts/legend_content/component.rb +122 -0
  34. data/app/components/poetry/charts/legend_content/preview.rb +23 -0
  35. data/app/components/poetry/charts/legend_content/style.rb +26 -0
  36. data/app/components/poetry/charts/line_chart/component.html.erb +85 -0
  37. data/app/components/poetry/charts/line_chart/component.rb +252 -0
  38. data/app/components/poetry/charts/line_chart/preview.rb +115 -0
  39. data/app/components/poetry/charts/line_chart/style.rb +28 -0
  40. data/app/components/poetry/charts/live.rb +296 -0
  41. data/app/components/poetry/charts/motion.rb +98 -0
  42. data/app/components/poetry/charts/pie_chart/component.html.erb +36 -0
  43. data/app/components/poetry/charts/pie_chart/component.rb +256 -0
  44. data/app/components/poetry/charts/pie_chart/preview.rb +100 -0
  45. data/app/components/poetry/charts/pie_chart/style.rb +19 -0
  46. data/app/components/poetry/charts/polar_family.rb +95 -0
  47. data/app/components/poetry/charts/radar_chart/component.html.erb +73 -0
  48. data/app/components/poetry/charts/radar_chart/component.rb +329 -0
  49. data/app/components/poetry/charts/radar_chart/preview.rb +97 -0
  50. data/app/components/poetry/charts/radar_chart/style.rb +21 -0
  51. data/app/components/poetry/charts/radial_bar_chart/component.html.erb +65 -0
  52. data/app/components/poetry/charts/radial_bar_chart/component.rb +424 -0
  53. data/app/components/poetry/charts/radial_bar_chart/preview.rb +99 -0
  54. data/app/components/poetry/charts/radial_bar_chart/style.rb +27 -0
  55. data/app/components/poetry/charts/reference_marks.rb +156 -0
  56. data/app/components/poetry/charts/scatter_chart/component.html.erb +55 -0
  57. data/app/components/poetry/charts/scatter_chart/component.rb +396 -0
  58. data/app/components/poetry/charts/scatter_chart/preview.rb +93 -0
  59. data/app/components/poetry/charts/scatter_chart/style.rb +22 -0
  60. data/app/components/poetry/charts/tooltip_content/component.html.erb +19 -0
  61. data/app/components/poetry/charts/tooltip_content/component.rb +168 -0
  62. data/app/components/poetry/charts/tooltip_content/preview.rb +39 -0
  63. data/app/components/poetry/charts/tooltip_content/style.rb +31 -0
  64. data/app/components/poetry/charts/tooltip_layer/component.html.erb +6 -0
  65. data/app/components/poetry/charts/tooltip_layer/component.rb +61 -0
  66. data/app/components/poetry/charts/tooltip_layer/style.rb +13 -0
  67. data/app/components/poetry/charts/tooltip_wiring.rb +167 -0
  68. data/app/helpers/poetry/charts/components_helper.rb +125 -0
  69. data/app/javascript/poetry/charts/adapter_controller.js +75 -0
  70. data/app/javascript/poetry/charts/adapter_registry.js +52 -0
  71. data/app/javascript/poetry/charts/adapters/chartjs.js +86 -0
  72. data/app/javascript/poetry/charts/d3.js +14 -0
  73. data/app/javascript/poetry/charts/index.js +67 -0
  74. data/app/javascript/poetry/charts/live/renderer.js +456 -0
  75. data/app/javascript/poetry/charts/live_controller.js +195 -0
  76. data/app/javascript/poetry/charts/motion/flip.js +169 -0
  77. data/app/javascript/poetry/charts/motion/sector.js +62 -0
  78. data/app/javascript/poetry/charts/motion/tween.js +105 -0
  79. data/app/javascript/poetry/charts/motion_controller.js +230 -0
  80. data/app/javascript/poetry/charts/tooltip_controller.js +331 -0
  81. data/app/javascript/poetry/charts/window_controller.js +195 -0
  82. data/config/component_descriptions.yml +14 -0
  83. data/config/component_registry.yml +3055 -0
  84. data/config/controllers_manifest.json +100 -0
  85. data/config/importmap.rb +10 -0
  86. data/lib/poetry/charts/cartesian.rb +277 -0
  87. data/lib/poetry/charts/config.rb +149 -0
  88. data/lib/poetry/charts/engine.rb +76 -0
  89. data/lib/poetry/charts/geometry/area.rb +68 -0
  90. data/lib/poetry/charts/geometry/curve.rb +327 -0
  91. data/lib/poetry/charts/geometry/line.rb +64 -0
  92. data/lib/poetry/charts/geometry/nice_ticks.rb +220 -0
  93. data/lib/poetry/charts/geometry/path.rb +96 -0
  94. data/lib/poetry/charts/geometry/scale/band.rb +81 -0
  95. data/lib/poetry/charts/geometry/scale/linear.rb +127 -0
  96. data/lib/poetry/charts/geometry/stack.rb +129 -0
  97. data/lib/poetry/charts/geometry/ticks.rb +103 -0
  98. data/lib/poetry/charts/geometry.rb +66 -0
  99. data/lib/poetry/charts/polar.rb +179 -0
  100. data/lib/poetry/charts/spec.rb +108 -0
  101. data/lib/poetry/charts/theme_style.rb +53 -0
  102. data/lib/poetry/charts/version.rb +8 -0
  103. data/lib/poetry/charts.rb +114 -0
  104. data/lib/poetry-charts.rb +4 -0
  105. data/themes/default.css +146 -0
  106. data/themes/luma.css +139 -0
  107. data/themes/lyra.css +140 -0
  108. data/themes/maia.css +139 -0
  109. data/themes/mira.css +139 -0
  110. data/themes/nova.css +139 -0
  111. data/themes/rhea.css +139 -0
  112. data/themes/sera.css +140 -0
  113. data/themes/vega.css +144 -0
  114. data/vendor/d3-kernel/LICENSE-d3.txt +17 -0
  115. data/vendor/d3-kernel/LICENSE-decimal.js-light.txt +25 -0
  116. data/vendor/d3-kernel/LICENSE-recharts.txt +26 -0
  117. data/vendor/d3-kernel/README.md +30 -0
  118. data/vendor/d3-kernel/build.mjs +66 -0
  119. data/vendor/d3-kernel/entry.js +27 -0
  120. data/vendor/d3-kernel/recharts/state/cartesianAxisSlice.ts +6 -0
  121. data/vendor/d3-kernel/recharts/util/scale/getNiceTickValues.ts +310 -0
  122. data/vendor/d3-kernel/recharts/util/scale/util/arithmetic.ts +54 -0
  123. data/vendor/d3-kernel/recharts/util/types.ts +5 -0
  124. 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
+ }