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,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module LineChart
|
|
6
|
+
# The line family across its variants: default (natural),
|
|
7
|
+
# linear, step, multiple series, dots, per-point dot colors, and
|
|
8
|
+
# point labels. Shared sample data.
|
|
9
|
+
class Preview < Poetry::Core::Preview::Base
|
|
10
|
+
DATA = AreaChart::Preview::DATA
|
|
11
|
+
ONE = AreaChart::Preview::ONE
|
|
12
|
+
TWO = AreaChart::Preview::TWO
|
|
13
|
+
MARGIN = AreaChart::Preview::MARGIN
|
|
14
|
+
|
|
15
|
+
BROWSER_CONFIG = {
|
|
16
|
+
visitors: { label: "Visitors", color: "var(--chart-2)" },
|
|
17
|
+
chrome: { label: "Chrome", color: "var(--chart-1)" },
|
|
18
|
+
safari: { label: "Safari", color: "var(--chart-2)" },
|
|
19
|
+
firefox: { label: "Firefox", color: "var(--chart-3)" },
|
|
20
|
+
edge: { label: "Edge", color: "var(--chart-4)" },
|
|
21
|
+
other: { label: "Other", color: "var(--chart-5)" }
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
BROWSER_DATA = [
|
|
25
|
+
{ browser: "chrome", visitors: 275, fill: "var(--color-chrome)" },
|
|
26
|
+
{ browser: "safari", visitors: 200, fill: "var(--color-safari)" },
|
|
27
|
+
{ browser: "firefox", visitors: 187, fill: "var(--color-firefox)" },
|
|
28
|
+
{ browser: "edge", visitors: 173, fill: "var(--color-edge)" },
|
|
29
|
+
{ browser: "other", visitors: 90, fill: "var(--color-other)" }
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
def default
|
|
33
|
+
simple_chart(id: "line-default", tooltip: true)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def linear
|
|
37
|
+
simple_chart(id: "line-linear", curve: :linear)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def step
|
|
41
|
+
simple_chart(id: "line-step", curve: :step)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def multiple
|
|
45
|
+
render_component(data: DATA, config: TWO, id: "line-multiple", margin: MARGIN) do |chart|
|
|
46
|
+
chart.with_grid
|
|
47
|
+
chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
|
|
48
|
+
chart.with_line(data_key: :desktop)
|
|
49
|
+
chart.with_line(data_key: :mobile)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def dots
|
|
54
|
+
simple_chart(id: "line-dots", dots: true)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def dots_colors
|
|
58
|
+
render_component(data: BROWSER_DATA, config: BROWSER_CONFIG, id: "line-dots-colors",
|
|
59
|
+
margin: { top: 24, left: 24, right: 24 }) do |chart|
|
|
60
|
+
chart.with_grid
|
|
61
|
+
chart.with_line(data_key: :visitors, dots: true, dot_radius: 5, dot_color_key: :fill)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def label
|
|
66
|
+
render_component(data: DATA, config: ONE, id: "line-label",
|
|
67
|
+
margin: { top: 20, left: 12, right: 12 }) do |chart|
|
|
68
|
+
chart.with_grid
|
|
69
|
+
chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
|
|
70
|
+
chart.with_line(data_key: :desktop, dots: true, labels: true)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def references_and_errors
|
|
75
|
+
data = AreaChart::Preview::DATA.map.with_index do |row, i|
|
|
76
|
+
row.merge(err: [12 + (i * 3), 20 - i])
|
|
77
|
+
end
|
|
78
|
+
render_component(data: data, config: ONE, id: "line-refs",
|
|
79
|
+
margin: { left: 12, right: 12 }) do |chart|
|
|
80
|
+
chart.with_grid
|
|
81
|
+
chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
|
|
82
|
+
chart.with_line(data_key: :desktop, error_key: :err)
|
|
83
|
+
chart.with_reference_line(y: 204, label: "avg")
|
|
84
|
+
chart.with_reference_area(y1: 280, y2: 320, label: "goal")
|
|
85
|
+
chart.with_tooltip
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The full live-window surface in one frame (see BarChart::Preview#
|
|
90
|
+
# live_window) - no tick_formatter: lambdas cannot ride the payload.
|
|
91
|
+
def live_window
|
|
92
|
+
render_component(data: DATA, config: ONE, id: "line-live", margin: MARGIN, live: true,
|
|
93
|
+
zoom: true, sync: "line-demo") do |chart|
|
|
94
|
+
chart.with_grid
|
|
95
|
+
chart.with_x_axis(data_key: :month)
|
|
96
|
+
chart.with_line(data_key: :desktop)
|
|
97
|
+
chart.with_tooltip(hide_label: true)
|
|
98
|
+
chart.with_brush
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def simple_chart(id:, curve: :natural, dots: false, tooltip: false)
|
|
105
|
+
render_component(data: DATA, config: ONE, id: id, margin: MARGIN) do |chart|
|
|
106
|
+
chart.with_grid
|
|
107
|
+
chart.with_x_axis(data_key: :month, tick_formatter: ->(v) { v[0, 3] })
|
|
108
|
+
chart.with_line(data_key: :desktop, curve: curve, dots: dots)
|
|
109
|
+
chart.with_tooltip(hide_label: true) if tooltip
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module LineChart
|
|
6
|
+
# The cartesian chrome on the shared cn-chart-* theme rules
|
|
7
|
+
# + the point-label look.
|
|
8
|
+
class Style < Poetry::Core::Style
|
|
9
|
+
base ""
|
|
10
|
+
|
|
11
|
+
element :frame, "contents"
|
|
12
|
+
element :svg, "w-full min-h-0 flex-1"
|
|
13
|
+
element :grid_line, "cn-chart-grid-line"
|
|
14
|
+
element :tick, "cn-chart-tick"
|
|
15
|
+
element :cursor_line, "cn-chart-cursor-line"
|
|
16
|
+
element :cursor_band, "cn-chart-cursor-band"
|
|
17
|
+
element :reference_line, "cn-chart-reference-line"
|
|
18
|
+
element :reference_area, "cn-chart-reference-area"
|
|
19
|
+
element :reference_dot, "cn-chart-reference-dot"
|
|
20
|
+
element :error_bar, "cn-chart-error-bar"
|
|
21
|
+
element :brush_track, "cn-chart-brush-track"
|
|
22
|
+
element :brush_window, "cn-chart-brush-window"
|
|
23
|
+
element :brush_handle, "cn-chart-brush-handle"
|
|
24
|
+
element :label, "cn-chart-label"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
# Live mode: a chart that opts in with `live: true` embeds a
|
|
6
|
+
# {spec, frame} payload the client renderer recomputes geometry from
|
|
7
|
+
# when data changes too often to round-trip to the server. `spec` is
|
|
8
|
+
# the FROZEN spec v1 built by the same Poetry::Charts::Spec the
|
|
9
|
+
# adapter seam uses (the spec stays closed); `frame` is a PRIVATE engine
|
|
10
|
+
# envelope carrying the geometry-affecting knobs the spec deliberately
|
|
11
|
+
# omits. Everything else about the chart stays server-rendered.
|
|
12
|
+
#
|
|
13
|
+
# Live charts cannot carry Ruby lambdas to the browser: tick
|
|
14
|
+
# formatters and label slots raise a teaching error - format data
|
|
15
|
+
# host-side (pre-formatted category strings) instead.
|
|
16
|
+
module Live
|
|
17
|
+
# The live controller's full-string identifier.
|
|
18
|
+
CONTROLLER = "poetry--charts--live"
|
|
19
|
+
|
|
20
|
+
def self.included(base)
|
|
21
|
+
base.extend(ClassMethods)
|
|
22
|
+
|
|
23
|
+
# Declared after TooltipWiring's and Motion's frame wirings
|
|
24
|
+
# (include order IS emission order): live registration + the host
|
|
25
|
+
# update hook, the tooltip re-read on live's updated dispatch, and
|
|
26
|
+
# the window controller's server-computed rects. The SVG gains the
|
|
27
|
+
# zoom drag actions; the payload <script> is live's data target.
|
|
28
|
+
base.use_stimulus do
|
|
29
|
+
on :frame do
|
|
30
|
+
controller CONTROLLER, if: :live? do
|
|
31
|
+
register
|
|
32
|
+
action :receive, on: "poetry-chart:update"
|
|
33
|
+
end
|
|
34
|
+
controller TooltipWiring::CONTROLLER, if: -> { live? && tooltip? } do
|
|
35
|
+
action :refresh, on: event(CONTROLLER, :updated)
|
|
36
|
+
end
|
|
37
|
+
controller WINDOW_CONTROLLER, if: :window_features? do
|
|
38
|
+
register
|
|
39
|
+
value :zoom, from: :zoom?
|
|
40
|
+
value :plot, from: :window_plot_json
|
|
41
|
+
value :brush, from: :window_brush_json, if: -> { brush_config }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
on :svg do
|
|
45
|
+
controller WINDOW_CONTROLLER, if: :zoom? do
|
|
46
|
+
action :startZoom, on: :pointerdown
|
|
47
|
+
action :reset, on: :dblclick
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
on :live_payload do
|
|
51
|
+
controller(CONTROLLER) { target :payload }
|
|
52
|
+
end
|
|
53
|
+
# Rendered by brush_svg via the stimulus_action descriptor -
|
|
54
|
+
# declared so the contract owns the token (and flags drift).
|
|
55
|
+
on :brush do
|
|
56
|
+
controller(WINDOW_CONTROLLER, if: -> { brush_config }) do
|
|
57
|
+
action :startBrush, on: :pointerdown
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The live-window anatomy (all server-rendered; the window
|
|
63
|
+
# controller only drags and repaints) - declared here so every
|
|
64
|
+
# live-capable family publishes the same styling surface.
|
|
65
|
+
base.part "chart-brush", "The brush strip group (with_brush): track + window + two " \
|
|
66
|
+
"handles below the x axis"
|
|
67
|
+
base.part "chart-brush-track", "The full-width brush rail"
|
|
68
|
+
base.part "chart-brush-window", "The selected-range rect the drag moves"
|
|
69
|
+
base.part "chart-brush-handle", "One draggable window edge",
|
|
70
|
+
states: { "data-edge" => { condition: "always - which edge",
|
|
71
|
+
values: %w[start end] } }
|
|
72
|
+
base.part "chart-zoom-selection", "The zoom drag-selection overlay (zoom: true), " \
|
|
73
|
+
"hidden until a drag starts"
|
|
74
|
+
base.part "chart-live-payload", "The embedded {spec, frame} JSON the live renderer " \
|
|
75
|
+
"recomputes geometry from"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The class-level macro families call to opt into live mode.
|
|
79
|
+
module ClassMethods
|
|
80
|
+
# Declares the live-mode surface: the live: and zoom: options plus
|
|
81
|
+
# the with_brush slot.
|
|
82
|
+
def live_option
|
|
83
|
+
option :live, :boolean, default: false,
|
|
84
|
+
doc: "Embeds the {spec, frame} payload so the client renderer can recompute " \
|
|
85
|
+
"geometry when data changes without a server round-trip."
|
|
86
|
+
option :zoom, :boolean, default: false,
|
|
87
|
+
doc: "Drag-to-zoom on the plot; slices the data client-side, so it needs live: true."
|
|
88
|
+
renders_one :brush,
|
|
89
|
+
doc: "The brush strip below the x axis - drag its window to slice the visible range; needs " \
|
|
90
|
+
"live: true.",
|
|
91
|
+
renders: lambda { |height: 30|
|
|
92
|
+
@brush_config = { height: height.to_f }
|
|
93
|
+
nil
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The live: option cast to a strict boolean.
|
|
99
|
+
# @api private
|
|
100
|
+
def live?
|
|
101
|
+
!!live
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The window controller's full-string identifier.
|
|
105
|
+
WINDOW_CONTROLLER = "poetry--charts--window"
|
|
106
|
+
# Pixels between the plot's bottom margin and the brush strip.
|
|
107
|
+
BRUSH_GAP = 8
|
|
108
|
+
|
|
109
|
+
# The brush slot's captured config, guarded: the window recomputes
|
|
110
|
+
# client-side, so a brush without live: true raises.
|
|
111
|
+
# @api private
|
|
112
|
+
def brush_config
|
|
113
|
+
brush?
|
|
114
|
+
if @brush_config && !live?
|
|
115
|
+
raise ArgumentError, "with_brush needs live: true - the window recomputes client-side"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@brush_config
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The zoom: option, guarded the same way as the brush.
|
|
122
|
+
# @api private
|
|
123
|
+
def zoom?
|
|
124
|
+
raise ArgumentError, "zoom: true needs live: true - the window recomputes client-side" if zoom && !live?
|
|
125
|
+
|
|
126
|
+
!!zoom
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Whether any windowing feature (brush or zoom) is active.
|
|
130
|
+
# @api private
|
|
131
|
+
def window_features?
|
|
132
|
+
!!brush_config || zoom?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The brush strip reserves space below the x axis by growing the
|
|
136
|
+
# bottom margin the cartesian sees.
|
|
137
|
+
# @api private
|
|
138
|
+
def live_margin
|
|
139
|
+
base = (margin || {}).to_h.symbolize_keys
|
|
140
|
+
return base unless brush_config
|
|
141
|
+
|
|
142
|
+
bottom = (base[:bottom] || Cartesian::DEFAULT_MARGIN[:bottom]).to_f
|
|
143
|
+
base.merge(bottom: bottom + brush_config[:height] + BRUSH_GAP)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The brush strip's y position - below the plot, above the reserved
|
|
147
|
+
# margin.
|
|
148
|
+
# @api private
|
|
149
|
+
def brush_top
|
|
150
|
+
original_bottom = (margin || {}).to_h.symbolize_keys.fetch(:bottom, Cartesian::DEFAULT_MARGIN[:bottom]).to_f
|
|
151
|
+
height - original_bottom - brush_config[:height]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The strip: track + window + two handles, all server-rendered; the
|
|
155
|
+
# window controller drags them and repaints on window changes.
|
|
156
|
+
# @api private
|
|
157
|
+
def brush_svg
|
|
158
|
+
config = brush_config
|
|
159
|
+
return unless config
|
|
160
|
+
|
|
161
|
+
left = cartesian.plot_left
|
|
162
|
+
width = cartesian.plot_right - left
|
|
163
|
+
top = brush_top
|
|
164
|
+
handle = 6.0
|
|
165
|
+
|
|
166
|
+
tag.g("data-slot": "chart-brush", "aria-hidden": true,
|
|
167
|
+
"data-action": stimulus_action(:startBrush, on: :pointerdown)) do
|
|
168
|
+
safe_join([
|
|
169
|
+
tag.rect(class: css(:brush_track), "data-slot": "chart-brush-track",
|
|
170
|
+
x: fnum(left), y: fnum(top), width: fnum(width),
|
|
171
|
+
height: fnum(config[:height]), rx: 4),
|
|
172
|
+
tag.rect(class: css(:brush_window), "data-slot": "chart-brush-window",
|
|
173
|
+
x: fnum(left), y: fnum(top), width: fnum(width),
|
|
174
|
+
height: fnum(config[:height]), rx: 4),
|
|
175
|
+
tag.rect(class: css(:brush_handle), "data-slot": "chart-brush-handle", "data-edge": "start",
|
|
176
|
+
x: fnum(left - (handle / 2)), y: fnum(top), width: fnum(handle),
|
|
177
|
+
height: fnum(config[:height]), rx: 2),
|
|
178
|
+
tag.rect(class: css(:brush_handle), "data-slot": "chart-brush-handle", "data-edge": "end",
|
|
179
|
+
x: fnum(left + width - (handle / 2)), y: fnum(top), width: fnum(handle),
|
|
180
|
+
height: fnum(config[:height]), rx: 2)
|
|
181
|
+
])
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The zoom drag-selection overlay (hidden until a drag starts).
|
|
186
|
+
# @api private
|
|
187
|
+
def zoom_selection_svg
|
|
188
|
+
return unless zoom?
|
|
189
|
+
|
|
190
|
+
tag.rect(class: css(:reference_area), "data-slot": "chart-zoom-selection", "aria-hidden": true,
|
|
191
|
+
x: 0, y: fnum(cartesian.plot_top), width: 0,
|
|
192
|
+
height: fnum(cartesian.plot_bottom - cartesian.plot_top),
|
|
193
|
+
"fill-opacity": 0.15, display: "none")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Value readers for the window controller's declared frame wiring:
|
|
197
|
+
# the plot rect and the brush strip rect, server-computed (the
|
|
198
|
+
# controller's only layout math is fractions of these).
|
|
199
|
+
# @api private
|
|
200
|
+
def window_plot_json
|
|
201
|
+
[cartesian.plot_left, cartesian.plot_right, cartesian.plot_top, cartesian.plot_bottom].to_json
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The brush strip rect for the window controller, server-computed.
|
|
205
|
+
# @api private
|
|
206
|
+
def window_brush_json
|
|
207
|
+
[cartesian.plot_left, brush_top, cartesian.plot_right - cartesian.plot_left,
|
|
208
|
+
brush_config[:height]].to_json
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The embedded {spec, frame} payload the live renderer recomputes
|
|
212
|
+
# geometry from, validated for serializability first.
|
|
213
|
+
# @api private
|
|
214
|
+
def live_payload
|
|
215
|
+
ensure_live_compatible!
|
|
216
|
+
{
|
|
217
|
+
"version" => 1,
|
|
218
|
+
"spec" => live_spec.to_h,
|
|
219
|
+
"frame" => live_frame
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The payload serialized for the embedded <script> tag.
|
|
224
|
+
# @api private
|
|
225
|
+
def live_payload_json
|
|
226
|
+
live_payload.to_json
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# The chart reduced to the closed spec (type, data, series, axes).
|
|
230
|
+
# @api private
|
|
231
|
+
def live_spec
|
|
232
|
+
Spec.new(type: live_type, data: data, series: live_series, axes: live_axes)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# The geometry-affecting knobs shared by the cartesian trio; families
|
|
236
|
+
# merge their extras (bar gaps/radii) via live_frame_extras.
|
|
237
|
+
# @api private
|
|
238
|
+
def live_frame
|
|
239
|
+
{
|
|
240
|
+
"width" => width,
|
|
241
|
+
"height" => height,
|
|
242
|
+
# live_margin, NOT the raw margin: the brush reserve must survive
|
|
243
|
+
# into client recomputes, or every window change drops the plot
|
|
244
|
+
# into the brush strip (the y-baseline sliding ~35px down).
|
|
245
|
+
"margin" => Cartesian::DEFAULT_MARGIN.merge(live_margin)
|
|
246
|
+
.transform_keys(&:to_s),
|
|
247
|
+
"layout" => cartesian.layout.to_s,
|
|
248
|
+
"xScaleType" => live_x_scale_type.to_s,
|
|
249
|
+
"categoryAxis" => live_category_axis?,
|
|
250
|
+
"valueAxis" => !cartesian.horizontal? && !y_axis_config.nil?,
|
|
251
|
+
"yTickCount" => cartesian.y_tick_count,
|
|
252
|
+
"offset" => cartesian.offset.to_s,
|
|
253
|
+
"xTickMargin" => x_axis_config&.tick_margin,
|
|
254
|
+
"yTickMargin" => y_axis_config&.tick_margin
|
|
255
|
+
}.merge(live_frame_extras).compact
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Hook: families merge extra geometry-affecting knobs into the frame
|
|
259
|
+
# envelope.
|
|
260
|
+
# @api private
|
|
261
|
+
def live_frame_extras
|
|
262
|
+
{}
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Lambdas cannot ride a JSON payload - fail at render, with the fix.
|
|
266
|
+
# @api private
|
|
267
|
+
def ensure_live_compatible!
|
|
268
|
+
if x_axis_config&.tick_formatter || y_axis_config&.tick_formatter
|
|
269
|
+
raise ArgumentError, "live: charts cannot serialize tick_formatter lambdas - " \
|
|
270
|
+
"pre-format the category strings in your data instead"
|
|
271
|
+
end
|
|
272
|
+
if series_entries.any? { |entry| entry.respond_to?(:error_key) && entry.error_key }
|
|
273
|
+
raise ArgumentError, "live: charts do not support error bars yet - " \
|
|
274
|
+
"drop error_key: or render without live:"
|
|
275
|
+
end
|
|
276
|
+
if respond_to?(:reference_marks) && reference_marks.any?
|
|
277
|
+
raise ArgumentError, "live: charts do not support reference marks yet"
|
|
278
|
+
end
|
|
279
|
+
return unless live_labels_configured?
|
|
280
|
+
|
|
281
|
+
raise ArgumentError, "live: charts do not support labels yet - " \
|
|
282
|
+
"drop labels: or render without live:"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Whether any series slot asked for value labels.
|
|
286
|
+
# @api private
|
|
287
|
+
def live_labels_configured?
|
|
288
|
+
series_entries.any? { |entry| entry.respond_to?(:labels) && entry.labels }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
private :brush_config, :zoom?, :window_features?, :live_margin, :brush_top, :brush_svg
|
|
292
|
+
private :zoom_selection_svg, :window_plot_json, :window_brush_json, :live_payload, :live_payload_json, :live_spec
|
|
293
|
+
private :live_frame, :live_frame_extras, :ensure_live_compatible!, :live_labels_configured?
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
# Shared animation surface for the chart families: chart-level
|
|
6
|
+
# options (animate / animation_duration / animation_easing /
|
|
7
|
+
# animation_begin, defaults set per family), emitted as data-animate plus
|
|
8
|
+
# --poetry-motion-* custom properties on the SVG. The animations
|
|
9
|
+
# themselves are CSS (the motion stylesheet) and the motion controller -
|
|
10
|
+
# the server computes all geometry; the client only interpolates
|
|
11
|
+
# between server-computed states.
|
|
12
|
+
module Motion
|
|
13
|
+
# The allowed animation_easing keywords.
|
|
14
|
+
EASINGS = %i[ease linear ease_in ease_out ease_in_out].freeze
|
|
15
|
+
# The motion controller's full-string identifier.
|
|
16
|
+
CONTROLLER = "poetry--charts--motion"
|
|
17
|
+
|
|
18
|
+
def self.included(base)
|
|
19
|
+
base.extend(ClassMethods)
|
|
20
|
+
|
|
21
|
+
# The motion controller rides the same frame element TooltipWiring
|
|
22
|
+
# declares (include order IS emission order: tooltip, motion, then
|
|
23
|
+
# live/window).
|
|
24
|
+
base.use_stimulus do
|
|
25
|
+
on :frame do
|
|
26
|
+
controller(CONTROLLER, if: :animate?) { register }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The class-level macro families call to declare their animation
|
|
32
|
+
# options.
|
|
33
|
+
module ClassMethods
|
|
34
|
+
# Declares the animation options with the family's defaults
|
|
35
|
+
# (bar 400ms, pie delayed 400ms, scatter 400ms linear,
|
|
36
|
+
# everything else 1500ms ease with no delay).
|
|
37
|
+
def motion_options(duration: 1500, delay: 0, easing: :ease)
|
|
38
|
+
option :animate, :boolean, default: true,
|
|
39
|
+
doc: "Entrance animation switch - reduced-motion users always get the finished " \
|
|
40
|
+
"chart regardless."
|
|
41
|
+
option :animation_duration, :integer, default: duration, doc: "Entrance/morph duration in milliseconds."
|
|
42
|
+
option :animation_easing, :symbol, default: easing, doc: "Animation easing keyword (see EASINGS)."
|
|
43
|
+
option :animation_begin, :integer, default: delay, doc: "Pre-animation hold in milliseconds."
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The animate: option cast to a strict boolean.
|
|
48
|
+
# @api private
|
|
49
|
+
def animate?
|
|
50
|
+
!!animate
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Merged into the SVG tag by TooltipWiring#svg_interaction_attributes.
|
|
54
|
+
# Duration/begin are :integer-typed and easing is enum-validated, so
|
|
55
|
+
# nothing user-controlled reaches the style attribute unguarded.
|
|
56
|
+
# @api private
|
|
57
|
+
def motion_svg_attributes
|
|
58
|
+
return {} unless animate?
|
|
59
|
+
|
|
60
|
+
{ "data-animate" => "", "style" => motion_style }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The --poetry-motion-* custom-property declarations for the SVG's
|
|
64
|
+
# style attribute.
|
|
65
|
+
# @api private
|
|
66
|
+
def motion_style
|
|
67
|
+
easing = animation_easing.to_sym
|
|
68
|
+
raise ArgumentError, "unknown easing #{animation_easing.inspect}" unless EASINGS.include?(easing)
|
|
69
|
+
|
|
70
|
+
style = "--poetry-motion-duration: #{animation_duration}ms; " \
|
|
71
|
+
"--poetry-motion-easing: #{easing.to_s.tr("_", "-")}; " \
|
|
72
|
+
"--poetry-motion-delay: #{animation_begin}ms"
|
|
73
|
+
extra = motion_style_extras
|
|
74
|
+
extra ? "#{style}; #{extra}" : style
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Hook: families append extra custom properties (radar ships the
|
|
78
|
+
# polar center so CSS can scale from it).
|
|
79
|
+
#
|
|
80
|
+
# @return [String, nil]
|
|
81
|
+
def motion_style_extras
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# data-motion-sector: the server-computed sector params the
|
|
86
|
+
# fan-out sweep reads (4-decimal formatting, matching sector_path's
|
|
87
|
+
# own fmt so mid-sweep client paths stay byte-compatible).
|
|
88
|
+
# @api private
|
|
89
|
+
def motion_sector_value(cx, cy, inner, outer, start_angle, end_angle)
|
|
90
|
+
[cx, cy, inner, outer, start_angle, end_angle]
|
|
91
|
+
.map { |v| Geometry.js_number((v.to_f * 10_000).round / 10_000.0) }
|
|
92
|
+
.join(" ")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private :animate?, :motion_style, :motion_sector_value
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<%= render Poetry::Charts::Container::Component.new(config: chart_config, id: chart_id.delete_prefix("chart-")) do %>
|
|
2
|
+
<%= content_tag(:div, frame_attributes.merge("class" => css(:frame))) do %>
|
|
3
|
+
<svg viewBox="0 0 <%= width %> <%= height %>" class="<%= css(:svg) %>" data-slot="chart-svg" aria-label="<%= svg_label %>" preserveAspectRatio="xMidYMid meet" <%= tag.attributes(svg_interaction_attributes) %>>
|
|
4
|
+
<% series_entries.each_with_index do |entry, pie_index| %>
|
|
5
|
+
<g data-slot="chart-pie" data-key="<%= entry.data_key %>">
|
|
6
|
+
<% slices(entry).each do |slice| %>
|
|
7
|
+
<path data-slot="chart-pie-sector" data-key="<%= entry.data_key %>"<% if pie_index.zero? %> data-index="<%= slice.index %>"<% end %><% if entry.active_index == slice.index %> data-active=""<% end %><% if animate? %> data-motion-sector="<%= motion_sector_value(cx, cy, slice.inner, slice.outer, slice.start_angle, slice.end_angle) %>" data-motion-group="pie-<%= pie_index %>"<% end %> d="<%= slice.path %>" fill="<%= slice.fill %>"<% if entry.stroke_width.positive? %> stroke="var(--background)" stroke-width="<%= entry.stroke_width %>"<% end %> />
|
|
8
|
+
<% end %>
|
|
9
|
+
</g>
|
|
10
|
+
<% if entry.labels == :list %>
|
|
11
|
+
<g data-slot="chart-labels" data-key="<%= entry.data_key %>" aria-hidden="true">
|
|
12
|
+
<% slices(entry).each do |slice| %>
|
|
13
|
+
<text class="<%= css(:inside_label) %>" x="<%= fnum(slice.label_point[0]) %>" y="<%= fnum(slice.label_point[1]) %>" text-anchor="middle" dy="0.355em"><%= slice_label(entry, slice) %></text>
|
|
14
|
+
<% end %>
|
|
15
|
+
</g>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% if center_label? %>
|
|
19
|
+
<%# dy=0.355em centers each line's midline on its y (the
|
|
20
|
+
dominant-baseline: middle offset, stated inline); the subtitle
|
|
21
|
+
sits 24 below the title, both absolute. %>
|
|
22
|
+
<text data-slot="chart-center-label" x="<%= fnum(cx) %>" y="<%= fnum(cy) %>" text-anchor="middle">
|
|
23
|
+
<tspan class="<%= css(:center_title) %>" x="<%= fnum(cx) %>" y="<%= fnum(cy) %>" dy="0.355em"><%= center_label_config[:title] %></tspan>
|
|
24
|
+
<% if center_label_config[:subtitle] %>
|
|
25
|
+
<tspan class="<%= css(:center_subtitle) %>" x="<%= fnum(cx) %>" y="<%= fnum(cy + 24) %>" dy="0.355em"><%= center_label_config[:subtitle] %></tspan>
|
|
26
|
+
<% end %>
|
|
27
|
+
</text>
|
|
28
|
+
<% end %>
|
|
29
|
+
</svg>
|
|
30
|
+
<% if legend? %>
|
|
31
|
+
<%= render legend_component %>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% if tooltip? %><%= render tooltip_layer_component %><% end %>
|
|
34
|
+
<script type="application/json" data-slot="chart-coordinates" <%= tag.attributes(stimulus_attributes_for(:coordinates)) %>><%= script_json(coordinates_json) %></script>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% end %>
|