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,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
|
+
}
|