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,169 @@
1
+ import { sectorPath } from "@poetry/charts/motion/sector"
2
+
3
+ // The FLIP geometry machinery (shared by the motion and live tiers):
4
+ // snapshot a chart's animatable geometry, pair it against the
5
+ // current DOM, and tween old -> new. Paths morph by pairwise numeric lerp
6
+ // behind a structure fingerprint, sectors by param lerp through the same
7
+ // sectorPath port, dots by cx/cy. Both endpoints are always
8
+ // server-computed (or kernel-computed) states - the only math here is
9
+ // a + (b - a) * t.
10
+
11
+ const MORPH_SLOTS = [
12
+ "chart-area",
13
+ "chart-area-stroke",
14
+ "chart-line",
15
+ "chart-bar",
16
+ "chart-radar",
17
+ "chart-pie-sector",
18
+ "chart-radial-bar",
19
+ "chart-dot",
20
+ ]
21
+ /** Selector matching every animatable mark the FLIP machinery drives. */
22
+ export const MORPH_SELECTOR = MORPH_SLOTS.map((slot) => `[data-slot="${slot}"]`).join(", ")
23
+
24
+ const NUMBER = /-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi
25
+
26
+ /**
27
+ * d -> { skeleton, numbers }: the non-numeric shape (command letters
28
+ * and separators) plus the numbers in order. Equal skeletons morph; arc
29
+ * flags are structural constants inside the skeleton's A-segments and
30
+ * stay put under lerp because equal skeletons imply equal flags.
31
+ *
32
+ * @param {string} d
33
+ * @returns {{ skeleton: string, numbers: number[] }}
34
+ */
35
+ export function parsePath(d) {
36
+ const numbers = []
37
+ const skeleton = d.replace(NUMBER, (match) => {
38
+ numbers.push(Number(match))
39
+ return "#"
40
+ })
41
+ return { skeleton, numbers }
42
+ }
43
+
44
+ /**
45
+ * Rebuilds a d string from a skeleton and numbers (2-decimal rounding).
46
+ *
47
+ * @param {string} skeleton
48
+ * @param {number[]} numbers
49
+ * @returns {string}
50
+ */
51
+ export function buildPath(skeleton, numbers) {
52
+ let i = 0
53
+ return skeleton.replace(/#/g, () => String(Math.round(numbers[i++] * 100) / 100))
54
+ }
55
+
56
+ /**
57
+ * Yields [key, element] for every animatable element, keyed by slot +
58
+ * series key + per-bucket sequence (dots carry no index; document order
59
+ * is stable).
60
+ *
61
+ * @param {SVGElement} svg
62
+ * @returns {Generator<[string, Element]>}
63
+ */
64
+ export function* keyedElements(svg) {
65
+ const counters = new Map()
66
+ for (const el of svg.querySelectorAll(MORPH_SELECTOR)) {
67
+ const base = `${el.dataset.slot}|${el.dataset.key ?? ""}`
68
+ const n = counters.get(base) ?? 0
69
+ counters.set(base, n + 1)
70
+ yield [`${base}|${n}`, el]
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Snapshots the animatable geometry (sector params, path d, or dot
76
+ * cx/cy), keyed for pairing.
77
+ *
78
+ * @param {SVGElement} svg
79
+ * @returns {Map<string, Object>}
80
+ */
81
+ export function captureGeometry(svg) {
82
+ const entries = new Map()
83
+ for (const [key, el] of keyedElements(svg)) {
84
+ if (el.dataset.motionSector) {
85
+ entries.set(key, { sector: el.dataset.motionSector.split(" ").map(Number) })
86
+ } else if (el.hasAttribute("d")) {
87
+ entries.set(key, { d: el.getAttribute("d") })
88
+ } else if (el.hasAttribute("cx")) {
89
+ entries.set(key, { cx: Number(el.getAttribute("cx")), cy: Number(el.getAttribute("cy")) })
90
+ }
91
+ }
92
+ return entries
93
+ }
94
+
95
+ /**
96
+ * Pairs every animatable element with its captured predecessor. Null =
97
+ * structure changed (missing key, skeleton mismatch, kind mismatch) -
98
+ * the caller falls back to its non-morph path.
99
+ *
100
+ * @param {SVGElement} svg
101
+ * @param {Map<string, Object>} previous - from `captureGeometry`
102
+ * @returns {Array<Object> | null} tween jobs, or null
103
+ */
104
+ export function matchJobs(svg, previous) {
105
+ const jobs = []
106
+ for (const [key, el] of keyedElements(svg)) {
107
+ const before = previous.get(key)
108
+ if (!before) return null
109
+
110
+ if (el.dataset.motionSector && before.sector) {
111
+ const to = el.dataset.motionSector.split(" ").map(Number)
112
+ if (to.length !== before.sector.length) return null
113
+ jobs.push({ el, sector: true, from: before.sector, to, final: el.getAttribute("d") })
114
+ } else if (el.hasAttribute("d") && before.d != null) {
115
+ const from = parsePath(before.d)
116
+ const to = parsePath(el.getAttribute("d"))
117
+ if (from.skeleton !== to.skeleton) return null
118
+ jobs.push({ el, skeleton: to.skeleton, from: from.numbers, to: to.numbers, final: el.getAttribute("d") })
119
+ } else if (el.hasAttribute("cx") && before.cx != null) {
120
+ jobs.push({
121
+ el,
122
+ from: [before.cx, before.cy],
123
+ to: [Number(el.getAttribute("cx")), Number(el.getAttribute("cy"))],
124
+ finalCx: el.getAttribute("cx"),
125
+ finalCy: el.getAttribute("cy"),
126
+ })
127
+ } else {
128
+ return null
129
+ }
130
+ }
131
+ return jobs.length ? jobs : null
132
+ }
133
+
134
+ /**
135
+ * Writes one interpolated frame of every job onto the DOM.
136
+ *
137
+ * @param {Array<Object>} jobs
138
+ * @param {number} eased - 0..1
139
+ */
140
+ export function applyJobs(jobs, eased) {
141
+ for (const job of jobs) {
142
+ if (job.sector) {
143
+ const p = job.from.map((from, i) => from + (job.to[i] - from) * eased)
144
+ job.el.setAttribute("d", sectorPath(p[0], p[1], p[2], p[3], p[4], p[5]))
145
+ } else if (job.skeleton) {
146
+ const numbers = job.from.map((from, i) => from + (job.to[i] - from) * eased)
147
+ job.el.setAttribute("d", buildPath(job.skeleton, numbers))
148
+ } else {
149
+ job.el.setAttribute("cx", String(job.from[0] + (job.to[0] - job.from[0]) * eased))
150
+ job.el.setAttribute("cy", String(job.from[1] + (job.to[1] - job.from[1]) * eased))
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * The final frame restores the exact target strings (server- or
157
+ * kernel-rendered), never a lerp artifact.
158
+ *
159
+ * @param {Array<Object>} jobs
160
+ */
161
+ export function finishJobs(jobs) {
162
+ for (const job of jobs) {
163
+ if (job.final != null) job.el.setAttribute("d", job.final)
164
+ else {
165
+ job.el.setAttribute("cx", job.finalCx)
166
+ job.el.setAttribute("cy", job.finalCy)
167
+ }
168
+ }
169
+ }
@@ -0,0 +1,62 @@
1
+ // The sector path, ported from lib/poetry/charts/polar.rb so both
2
+ // sides emit identical path strings. Angles are degrees
3
+ // counterclockwise from 3 o'clock, negated into SVG's y-down plane; the
4
+ // delta clamps at 359.999 so a full circle's endpoints never coincide.
5
+ // 4-decimal formatting matches the Ruby fmt (native JS stringification IS
6
+ // the reference Geometry.js_number emulates), so mid-sweep paths and the
7
+ // server's plain paths are byte-compatible.
8
+
9
+ const RADIAN = Math.PI / 180
10
+
11
+ const sign = (value) => (value === 0 ? 0 : value < 0 ? -1 : 1)
12
+ const fmt = (value) => String(Math.round(value * 1e4) / 1e4)
13
+
14
+ /**
15
+ * Degrees-and-radius to SVG coordinates (the y-down plane).
16
+ *
17
+ * @param {number} cx
18
+ * @param {number} cy
19
+ * @param {number} radius
20
+ * @param {number} angle - degrees counterclockwise from 3 o'clock
21
+ * @returns {[number, number]} [x, y]
22
+ */
23
+ export function polarToCartesian(cx, cy, radius, angle) {
24
+ return [cx + Math.cos(-RADIAN * angle) * radius, cy + Math.sin(-RADIAN * angle) * radius]
25
+ }
26
+
27
+ /**
28
+ * The annular-sector path for a pie/radial slice; inner radius 0 closes
29
+ * to the center instead of an inner arc.
30
+ *
31
+ * @param {number} cx
32
+ * @param {number} cy
33
+ * @param {number} innerRadius
34
+ * @param {number} outerRadius
35
+ * @param {number} startAngle - degrees
36
+ * @param {number} endAngle
37
+ * @returns {string} the d attribute
38
+ */
39
+ export function sectorPath(cx, cy, innerRadius, outerRadius, startAngle, endAngle) {
40
+ const delta = sign(endAngle - startAngle) * Math.min(Math.abs(endAngle - startAngle), 359.999)
41
+ const tempEnd = startAngle + delta
42
+ const large = Math.abs(delta) > 180 ? 1 : 0
43
+
44
+ const [ox0, oy0] = polarToCartesian(cx, cy, outerRadius, startAngle)
45
+ const [ox1, oy1] = polarToCartesian(cx, cy, outerRadius, tempEnd)
46
+
47
+ let path = `M${fmt(ox0)},${fmt(oy0)}` +
48
+ `A${fmt(outerRadius)},${fmt(outerRadius)},0,` +
49
+ `${large},${startAngle > tempEnd ? 1 : 0},${fmt(ox1)},${fmt(oy1)}`
50
+
51
+ if (innerRadius > 0) {
52
+ const [ix0, iy0] = polarToCartesian(cx, cy, innerRadius, startAngle)
53
+ const [ix1, iy1] = polarToCartesian(cx, cy, innerRadius, tempEnd)
54
+ path += `L${fmt(ix1)},${fmt(iy1)}` +
55
+ `A${fmt(innerRadius)},${fmt(innerRadius)},0,` +
56
+ `${large},${startAngle <= tempEnd ? 1 : 0},${fmt(ix0)},${fmt(iy0)}Z`
57
+ } else {
58
+ path += `L${fmt(cx)},${fmt(cy)}Z`
59
+ }
60
+
61
+ return path
62
+ }
@@ -0,0 +1,105 @@
1
+ // The tween kernel: one rAF loop, the CSS-named cubic-bezier
2
+ // easings, cancelable. An animation runtime scoped to poetry's
3
+ // doctrine - the client only interpolates between server-computed
4
+ // states, so the whole kernel is eased time.
5
+
6
+ /**
7
+ * A CSS cubic-bezier timing function: Newton-Raphson with a bisection
8
+ * fallback - the standard solver (the same approach WebKit ships).
9
+ *
10
+ * @param {number} x1
11
+ * @param {number} y1
12
+ * @param {number} x2
13
+ * @param {number} y2
14
+ * @returns {(x: number) => number} eased progress for linear progress
15
+ */
16
+ export function cubicBezier(x1, y1, x2, y2) {
17
+ const ax = 3 * x1 - 3 * x2 + 1
18
+ const bx = 3 * x2 - 6 * x1
19
+ const cx = 3 * x1
20
+ const ay = 3 * y1 - 3 * y2 + 1
21
+ const by = 3 * y2 - 6 * y1
22
+ const cy = 3 * y1
23
+
24
+ const sampleX = (t) => ((ax * t + bx) * t + cx) * t
25
+ const sampleY = (t) => ((ay * t + by) * t + cy) * t
26
+ const sampleDX = (t) => (3 * ax * t + 2 * bx) * t + cx
27
+
28
+ const solveT = (x) => {
29
+ let t = x
30
+ for (let i = 0; i < 8; i++) {
31
+ const dx = sampleX(t) - x
32
+ if (Math.abs(dx) < 1e-6) return t
33
+ const d = sampleDX(t)
34
+ if (Math.abs(d) < 1e-6) break
35
+ t -= dx / d
36
+ }
37
+ let lo = 0
38
+ let hi = 1
39
+ t = x
40
+ while (lo < hi) {
41
+ const dx = sampleX(t) - x
42
+ if (Math.abs(dx) < 1e-6) return t
43
+ if (dx > 0) hi = t
44
+ else lo = t
45
+ t = (lo + hi) / 2
46
+ }
47
+ return t
48
+ }
49
+
50
+ return (x) => {
51
+ if (x <= 0) return 0
52
+ if (x >= 1) return 1
53
+ return sampleY(solveT(x))
54
+ }
55
+ }
56
+
57
+ /** The CSS-named easing functions. */
58
+ export const EASINGS = {
59
+ linear: (t) => t,
60
+ ease: cubicBezier(0.25, 0.1, 0.25, 1),
61
+ "ease-in": cubicBezier(0.42, 0, 1, 1),
62
+ "ease-out": cubicBezier(0, 0, 0.58, 1),
63
+ "ease-in-out": cubicBezier(0.42, 0, 0.58, 1),
64
+ }
65
+
66
+ /**
67
+ * Runs one rAF tween. onFrame receives (eased, linear); the final frame
68
+ * is exactly (1, 1). A non-positive duration fires the final frame
69
+ * synchronously.
70
+ *
71
+ * @param {Object} options
72
+ * @param {number} options.duration - ms
73
+ * @param {number} [options.delay=0] - ms before the first eased frame
74
+ * @param {string | Function} [options.easing="ease"] - an EASINGS name
75
+ * or a timing function
76
+ * @param {(eased: number, linear: number) => void} options.onFrame
77
+ * @param {() => void} [options.onFinish]
78
+ * @returns {() => void} cancel()
79
+ */
80
+ export function tween({ duration, delay = 0, easing = "ease", onFrame, onFinish }) {
81
+ const fn = typeof easing === "function" ? easing : (EASINGS[easing] ?? EASINGS.ease)
82
+
83
+ if (!(duration > 0)) {
84
+ onFrame(1, 1)
85
+ onFinish?.()
86
+ return () => {}
87
+ }
88
+
89
+ let raf
90
+ let start
91
+ const step = (now) => {
92
+ start ??= now
93
+ const elapsed = now - start - delay
94
+ if (elapsed < 0) {
95
+ raf = requestAnimationFrame(step)
96
+ return
97
+ }
98
+ const t = Math.min(1, elapsed / duration)
99
+ onFrame(fn(t), t)
100
+ if (t < 1) raf = requestAnimationFrame(step)
101
+ else onFinish?.()
102
+ }
103
+ raf = requestAnimationFrame(step)
104
+ return () => cancelAnimationFrame(raf)
105
+ }
@@ -0,0 +1,230 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { tween } from "@poetry/charts/motion/tween"
3
+ import { sectorPath } from "@poetry/charts/motion/sector"
4
+ import { captureGeometry, matchJobs, applyJobs, finishJobs } from "@poetry/charts/motion/flip"
5
+
6
+ // The chart motion engine: the JS half of the entrance tier plus
7
+ // the cross-render morph. Cartesian + radar entrances are pure CSS (the
8
+ // motion stylesheet); this controller adds what CSS cannot do - the polar
9
+ // fan-out and the FLIP morph between server renders - and stamps a
10
+ // data-motion lifecycle attribute ("entrance" / "morph" -> "settled") on
11
+ // the SVG so tests and hosts can observe the engine.
12
+ //
13
+ // ENTRANCE. The fan-out is a step accumulator: every sector's
14
+ // angular width interpolates 0 -> final
15
+ // simultaneously, re-accumulated end-to-end each frame from the group's
16
+ // first startAngle, with the FINAL-geometry gaps preserved as constant
17
+ // padding. Sectors carry their server-computed params in
18
+ // data-motion-sector and their sweep group in data-motion-group (pie: one
19
+ // group per ring; radial: one per band). Mid-sweep paths are plain
20
+ // sectors; the exact server d (corner rounding included) is restored on
21
+ // the final frame.
22
+ //
23
+ // MORPH. Charts with a stable id keep their last geometry in a
24
+ // module registry when they disconnect; when a same-id chart connects
25
+ // within the freshness window (any same-context DOM swap - Turbo Drive /
26
+ // Frames / Streams / morph), the new render starts FROM the old geometry
27
+ // and tweens to its own via the shared FLIP machinery (motion/flip.js -
28
+ // the live tier rides the same module). Any structure change
29
+ // aborts the morph and the normal entrance replays instead - added or
30
+ // removed data never morphs.
31
+ //
32
+ // prefers-reduced-motion (or animate: false) settles immediately - the
33
+ // server-rendered chart is already the finished state.
34
+
35
+ const MORPH_WINDOW_MS = 5000
36
+
37
+ // chartId -> { at, entries } across controller lifetimes (module-scoped:
38
+ // the old render's controller writes it on disconnect, the new render's
39
+ // reads it on connect - same JS context, so Turbo swaps carry it over).
40
+ const registry = new Map()
41
+
42
+ export default class ChartMotionController extends Controller {
43
+ // The events this controller dispatches (manifest surface;
44
+ // events_declaration.test.js enforces the list stays honest).
45
+ static events = ["poetry--charts--motion:settled"]
46
+
47
+ #onBeforeRender = null
48
+ #onTurboMorph = null
49
+
50
+ /**
51
+ * Resolves the SVG, wires the Turbo page-morph hooks (the body
52
+ * comment holds the blank-entrance rule), and starts the entrance or
53
+ * morph.
54
+ */
55
+ connect() {
56
+ this.svg = this.element.querySelector('[data-slot="chart-svg"]')
57
+ if (!this.svg) return
58
+ this.chartId = this.element.closest("[data-chart]")?.dataset.chart
59
+
60
+ // A Turbo PAGE morph preserves this element (no disconnect/connect),
61
+ // yet replaces the SVG - without these hooks the fresh render replays
62
+ // the CSS entrance from blank (the area chart's scaleX(0) frame).
63
+ // Capture geometry before the morph, then rejoin the normal flow.
64
+ this.#onBeforeRender = (event) => {
65
+ if (event.detail?.renderMethod !== "morph") return
66
+ if (this.chartId && this.svg?.isConnected) {
67
+ registry.set(this.chartId, { at: performance.now(), entries: captureGeometry(this.svg) })
68
+ }
69
+ }
70
+ this.#onTurboMorph = () => this.#restartAfterTurboMorph()
71
+ document.addEventListener("turbo:before-render", this.#onBeforeRender)
72
+ document.addEventListener("turbo:morph", this.#onTurboMorph)
73
+
74
+ this.#start()
75
+ }
76
+
77
+ #start() {
78
+ if (!this.svg.hasAttribute("data-animate") || this.#reducedMotion()) {
79
+ this.#settle()
80
+ return
81
+ }
82
+
83
+ if (this.#morphFromRegistry()) return
84
+
85
+ const sectors = [...this.svg.querySelectorAll("[data-motion-sector]")]
86
+ if (sectors.length) this.#sweep(sectors)
87
+ else this.#watchCssEntrance()
88
+ }
89
+
90
+ #restartAfterTurboMorph() {
91
+ const svg = this.element.querySelector('[data-slot="chart-svg"]')
92
+ if (!svg) return
93
+
94
+ this.cancel?.()
95
+ this.cancel = null
96
+ this.svg = svg
97
+ this.#start()
98
+ }
99
+
100
+ /**
101
+ * Unwires the Turbo hooks, abandons any tween, and parks the current
102
+ * geometry in the registry for a same-id successor.
103
+ */
104
+ disconnect() {
105
+ document.removeEventListener("turbo:before-render", this.#onBeforeRender)
106
+ document.removeEventListener("turbo:morph", this.#onTurboMorph)
107
+ this.cancel?.()
108
+ this.cancel = null
109
+ if (this.chartId && this.svg) {
110
+ registry.set(this.chartId, { at: performance.now(), entries: captureGeometry(this.svg) })
111
+ }
112
+ }
113
+
114
+ // -- the cross-render morph -----------------------------------------------
115
+
116
+ #morphFromRegistry() {
117
+ if (!this.chartId) return false
118
+ const previous = registry.get(this.chartId)
119
+ if (!previous) return false
120
+ registry.delete(this.chartId)
121
+ if (performance.now() - previous.at > MORPH_WINDOW_MS) return false
122
+
123
+ const jobs = matchJobs(this.svg, previous.entries)
124
+ if (!jobs) return false
125
+
126
+ // Stamped before the first paint of the new render, so the morph-kill
127
+ // CSS rule suppresses the entrance animations that would otherwise
128
+ // start on the fresh elements.
129
+ this.svg.setAttribute("data-motion", "morph")
130
+ applyJobs(jobs, 0) // FLIP: the new render starts at the OLD geometry
131
+
132
+ this.cancel = tween({
133
+ duration: this.#number("--poetry-motion-duration", 1500),
134
+ easing: this.#styleVar("--poetry-motion-easing") ?? "ease",
135
+ onFrame: (eased) => applyJobs(jobs, eased),
136
+ onFinish: () => {
137
+ finishJobs(jobs)
138
+ this.#settle()
139
+ },
140
+ })
141
+ return true
142
+ }
143
+
144
+ // -- the polar fan-out ------------------------------------------------
145
+
146
+ #sweep(elements) {
147
+ const groups = new Map()
148
+ for (const el of elements) {
149
+ const [cx, cy, inner, outer, start, end] = el.dataset.motionSector.split(" ").map(Number)
150
+ const key = el.dataset.motionGroup ?? "all"
151
+ if (!groups.has(key)) groups.set(key, [])
152
+ groups.get(key).push({ el, cx, cy, inner, outer, start, end, final: el.getAttribute("d") })
153
+ }
154
+
155
+ // Hold the entrance state through animation_begin - the delay
156
+ // shows the entrance state, not the finished chart.
157
+ this.svg.setAttribute("data-motion", "entrance")
158
+ const frame = (eased) => {
159
+ for (const sectors of groups.values()) {
160
+ let cursor = sectors[0].start
161
+ let previousEnd = sectors[0].start
162
+ for (const s of sectors) {
163
+ const pad = s.start - previousEnd
164
+ const delta = (s.end - s.start) * eased
165
+ const from = cursor + pad
166
+ s.el.setAttribute("d", sectorPath(s.cx, s.cy, s.inner, s.outer, from, from + delta))
167
+ previousEnd = s.end
168
+ cursor = from + delta
169
+ }
170
+ }
171
+ }
172
+ frame(0)
173
+
174
+ this.cancel = tween({
175
+ duration: this.#number("--poetry-motion-duration", 1500),
176
+ delay: this.#number("--poetry-motion-delay", 0),
177
+ easing: this.#styleVar("--poetry-motion-easing") ?? "ease",
178
+ onFrame: frame,
179
+ onFinish: () => {
180
+ for (const sectors of groups.values()) {
181
+ for (const s of sectors) s.el.setAttribute("d", s.final)
182
+ }
183
+ this.#settle()
184
+ },
185
+ })
186
+ }
187
+
188
+ // -- the CSS entrance observer ------------------------------------------
189
+
190
+ #watchCssEntrance() {
191
+ if (typeof this.svg.getAnimations !== "function") {
192
+ this.#settle()
193
+ return
194
+ }
195
+ const animations = this.svg
196
+ .getAnimations({ subtree: true })
197
+ .filter((a) => a.animationName?.startsWith("poetry-chart"))
198
+ if (!animations.length) {
199
+ this.#settle()
200
+ return
201
+ }
202
+ this.svg.setAttribute("data-motion", "entrance")
203
+ Promise.allSettled(animations.map((a) => a.finished)).then(() => {
204
+ if (this.svg.isConnected) this.#settle()
205
+ })
206
+ }
207
+
208
+ // -- shared ---------------------------------------------------------------
209
+
210
+ #settle() {
211
+ this.svg.setAttribute("data-motion", "settled")
212
+ this.dispatch("settled")
213
+ }
214
+
215
+ // The --poetry-motion-* knobs ride the SVG's inline style - our own wire
216
+ // format, so parse the attribute (jsdom-safe, the tooltip's viewBox lesson).
217
+ #styleVar(name) {
218
+ const match = (this.svg.getAttribute("style") ?? "").match(new RegExp(`${name}:\\s*([^;]+)`))
219
+ return match ? match[1].trim() : null
220
+ }
221
+
222
+ #number(name, fallback) {
223
+ const value = parseFloat(this.#styleVar(name))
224
+ return Number.isFinite(value) ? value : fallback
225
+ }
226
+
227
+ #reducedMotion() {
228
+ return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches
229
+ }
230
+ }