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,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module Container
|
|
6
|
+
# The frame previewed standalone: config-driven series colors flowing
|
|
7
|
+
# into inline SVG through var(--color-<key>) - the whole container
|
|
8
|
+
# contract, before any engine exists.
|
|
9
|
+
class Preview < Poetry::Core::Preview::Base
|
|
10
|
+
CONFIG = {
|
|
11
|
+
desktop: { label: "Desktop", color: "var(--chart-1)" },
|
|
12
|
+
mobile: { label: "Mobile", color: "var(--chart-2)" }
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def default
|
|
16
|
+
render_component(config: CONFIG, id: "preview") do
|
|
17
|
+
<<~SVG.html_safe
|
|
18
|
+
<svg viewBox="0 0 300 150" style="height:100%;width:100%" role="img" aria-label="Placeholder chart">
|
|
19
|
+
<rect x="20" y="60" width="40" height="80" rx="4" fill="var(--color-desktop)"></rect>
|
|
20
|
+
<rect x="70" y="30" width="40" height="110" rx="4" fill="var(--color-mobile)"></rect>
|
|
21
|
+
<rect x="130" y="80" width="40" height="60" rx="4" fill="var(--color-desktop)"></rect>
|
|
22
|
+
<rect x="180" y="45" width="40" height="95" rx="4" fill="var(--color-mobile)"></rect>
|
|
23
|
+
</svg>
|
|
24
|
+
SVG
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def themed
|
|
29
|
+
render_component(
|
|
30
|
+
config: {
|
|
31
|
+
revenue: { label: "Revenue", theme: { light: "oklch(0.6 0.15 250)", dark: "oklch(0.75 0.12 250)" } }
|
|
32
|
+
},
|
|
33
|
+
id: "preview-themed"
|
|
34
|
+
) do
|
|
35
|
+
<<~SVG.html_safe
|
|
36
|
+
<svg viewBox="0 0 300 150" style="height:100%;width:100%" role="img" aria-label="Themed placeholder chart">
|
|
37
|
+
<circle cx="150" cy="75" r="50" fill="var(--color-revenue)"></circle>
|
|
38
|
+
</svg>
|
|
39
|
+
SVG
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module Container
|
|
6
|
+
# The chart frame chrome, with NO descendant overrides into the
|
|
7
|
+
# chart body - descendant selectors exist to style DOM one does not
|
|
8
|
+
# own, and poetry OWNS its SVG, so tick text (muted-foreground),
|
|
9
|
+
# grid (border/50), and sector/dot outline-hidden land as direct
|
|
10
|
+
# classes on the engine's own parts; the container keeps only what
|
|
11
|
+
# is truly container-level. Adapter/island content brings its own
|
|
12
|
+
# inner styling (a declared degradation).
|
|
13
|
+
class Style < Poetry::Core::Style
|
|
14
|
+
# relative anchors the absolute tooltip layer (visual no-op).
|
|
15
|
+
# flex-col stacks the legend under (or over, via order-first) the
|
|
16
|
+
# chart SVG, reserving plot space for it; a row layout would park
|
|
17
|
+
# the legend beside the plot.
|
|
18
|
+
base "relative flex aspect-video flex-col items-center justify-center text-xs [&_svg]:outline-hidden"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
# Error bars for line, bar, and scatter series. A series slot takes
|
|
6
|
+
# error_key: naming a row key holding either a symmetric offset (err)
|
|
7
|
+
# or [low, high] offsets from the value; the whisker renders
|
|
8
|
+
# cap - stem - cap in the foreground color (a neutral that stays
|
|
9
|
+
# visible over same-colored marks), width 5.
|
|
10
|
+
# @api private
|
|
11
|
+
module ErrorBars
|
|
12
|
+
# Whisker cap width in pixels.
|
|
13
|
+
DEFAULT_WIDTH = 5
|
|
14
|
+
|
|
15
|
+
# [low_value, high_value] in DATA units, or nil when the row has no
|
|
16
|
+
# error entry.
|
|
17
|
+
def error_range(row, error_key, value)
|
|
18
|
+
err = row.to_h.transform_keys(&:to_s)[error_key.to_s]
|
|
19
|
+
return nil if err.nil?
|
|
20
|
+
|
|
21
|
+
low, high = err.is_a?(Array) ? err : [err, err]
|
|
22
|
+
[value - low.to_f, value + high.to_f]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# cap - stem - cap around a vertical span, in pixels.
|
|
26
|
+
def error_bar_path(center_x, y_low, y_high, width)
|
|
27
|
+
half = width.to_f / 2.0
|
|
28
|
+
f = ->(v) { fnum(v) }
|
|
29
|
+
"M#{f.call(center_x - half)},#{f.call(y_low)}H#{f.call(center_x + half)}" \
|
|
30
|
+
"M#{f.call(center_x)},#{f.call(y_low)}V#{f.call(y_high)}" \
|
|
31
|
+
"M#{f.call(center_x - half)},#{f.call(y_high)}H#{f.call(center_x + half)}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%= content_tag(:div, **root_attributes.to_attributes) do %>
|
|
2
|
+
<% rows.each do |row| %>
|
|
3
|
+
<%= content_tag(toggle ? :button : :div, **(toggle ? toggle_attributes(row) : { data: { slot: "chart-legend-item" }, class: css(:item) })) do %>
|
|
4
|
+
<% unless hide_icon || row[:color].nil? %>
|
|
5
|
+
<div data-slot="chart-legend-swatch" class="<%= css(:swatch) %>" style="<%= swatch_style(row[:color]) %>"></div>
|
|
6
|
+
<% end %>
|
|
7
|
+
<%= row[:name] %>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
# The chart legend chrome.
|
|
6
|
+
module LegendContent
|
|
7
|
+
# The legend chrome: a centered row of swatch + label pairs. Items
|
|
8
|
+
# default to the config's colored entries (the common case - every
|
|
9
|
+
# configured series, in config order); pass items: [{ key:, color: }]
|
|
10
|
+
# to override, e.g. per-slice pie legends.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# <%= poetry_chart_legend_content(config: config) %>
|
|
14
|
+
class Component < Poetry::Core::Component
|
|
15
|
+
# Projected into the registry and agent surface.
|
|
16
|
+
AGENT_RULES = [
|
|
17
|
+
"The legend derives from the chart config by default - omit items: unless slices differ from series.",
|
|
18
|
+
"align: :top pads below (pb-3), :bottom (default) pads above (pt-3) - matching the chart edge it sits on."
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
style :align, default: :bottom, required: true, variants: %i[top bottom],
|
|
22
|
+
doc: "Which chart edge the legend sits on: :top pads below it, :bottom (the default) pads " \
|
|
23
|
+
"above. A style axis, not an option - options silently drop the dictionary's variant " \
|
|
24
|
+
"classes."
|
|
25
|
+
|
|
26
|
+
option :config, ActiveModel::Type::Value.new, required: true,
|
|
27
|
+
doc: "The series config - key => { label:, color: } - the " \
|
|
28
|
+
"default item source."
|
|
29
|
+
option :items, ActiveModel::Type::Value.new,
|
|
30
|
+
doc: "Explicit entries ([{ key:, name:, color: }]) overriding the config-derived list."
|
|
31
|
+
option :hide_icon, :boolean, default: false, doc: "Hides the color swatches, leaving labels only."
|
|
32
|
+
option :toggle, :boolean, default: false,
|
|
33
|
+
doc: "Interactive legend: items render as buttons that toggle their series through " \
|
|
34
|
+
"the live controller (the host chart guards that live: is on)."
|
|
35
|
+
|
|
36
|
+
part "chart-legend-content", "The legend row (<div>) - centered swatch + label pairs"
|
|
37
|
+
part "chart-legend-item", "One legend entry (<div>; a <button> in toggle mode)",
|
|
38
|
+
states: {
|
|
39
|
+
"data-key" => "in toggle mode - the series key the button toggles",
|
|
40
|
+
"data-hidden" => "in toggle mode - the item's series is toggled off (the live " \
|
|
41
|
+
"controller stamps it at runtime; the item dims)"
|
|
42
|
+
}
|
|
43
|
+
part "chart-legend-swatch", "The color swatch (<div>) - inline background-color carries " \
|
|
44
|
+
"the entry's color; omitted with hide_icon or colorless entries"
|
|
45
|
+
|
|
46
|
+
# The config: option wrapped as a {Poetry::Charts::Config}.
|
|
47
|
+
# @api private
|
|
48
|
+
def chart_config
|
|
49
|
+
@chart_config ||= Poetry::Charts::Config.wrap(config)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The resolved entries: explicit items:, else the config's
|
|
53
|
+
# colored entries in config order.
|
|
54
|
+
# @api private
|
|
55
|
+
def rows
|
|
56
|
+
@rows ||= if items.present?
|
|
57
|
+
Array(items).map { |item| resolve(item.symbolize_keys) }
|
|
58
|
+
else
|
|
59
|
+
chart_config.color_entries.map do |entry|
|
|
60
|
+
{ key: entry.key, name: entry.label || entry.key,
|
|
61
|
+
color: entry.color_for(:light) ? swatch_color(entry) : nil }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The toggle buttons speak straight to the live controller on the
|
|
67
|
+
# chart frame (Stimulus action params carry the series key).
|
|
68
|
+
# @api private
|
|
69
|
+
def toggle_attributes(row)
|
|
70
|
+
{
|
|
71
|
+
type: "button",
|
|
72
|
+
data: {
|
|
73
|
+
slot: "chart-legend-item",
|
|
74
|
+
key: row[:key],
|
|
75
|
+
action: "click->poetry--charts--live#toggleSeries",
|
|
76
|
+
"poetry--charts--live-key-param": row[:key]
|
|
77
|
+
},
|
|
78
|
+
class: "#{css(:item)} #{css(:toggle)}"
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The legend row's attributes with the part self-identification.
|
|
83
|
+
# @api private
|
|
84
|
+
def root_attributes
|
|
85
|
+
html_attributes.merge_if_not_set(
|
|
86
|
+
{ "data-slot" => "chart-legend-content" }.merge(component_data_attributes)
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A swatch's guarded inline background (colors reaching a style
|
|
91
|
+
# attribute must be CSS-safe).
|
|
92
|
+
# @api private
|
|
93
|
+
def swatch_style(color)
|
|
94
|
+
return nil unless color
|
|
95
|
+
raise ArgumentError, "legend color #{color.inspect} is not CSS-safe" unless color.match?(Config::COLOR)
|
|
96
|
+
|
|
97
|
+
"background-color: #{color};"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Themed entries swatch through var(--color-<key>) (the container's
|
|
103
|
+
# emission), so the swatch follows dark mode; flat colors inline.
|
|
104
|
+
def swatch_color(entry)
|
|
105
|
+
entry.theme ? "var(--color-#{entry.key})" : entry.color
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolve(item)
|
|
109
|
+
key = (item[:key] || item[:name]).to_s
|
|
110
|
+
entry = chart_config[key]
|
|
111
|
+
{
|
|
112
|
+
key: key,
|
|
113
|
+
name: chart_config.label_for(key, item[:name]),
|
|
114
|
+
color: item[:color] || (entry && swatch_color(entry))
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private :chart_config, :rows, :toggle_attributes, :root_attributes, :swatch_style
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module LegendContent
|
|
6
|
+
# The legend chrome: config-derived (the default) and explicit items.
|
|
7
|
+
class Preview < Poetry::Core::Preview::Base
|
|
8
|
+
CONFIG = {
|
|
9
|
+
desktop: { label: "Desktop", color: "var(--chart-1)" },
|
|
10
|
+
mobile: { label: "Mobile", color: "var(--chart-2)" }
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def default
|
|
14
|
+
render_component(config: CONFIG)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def top_aligned
|
|
18
|
+
render_component(config: CONFIG, align: :top)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
module LegendContent
|
|
6
|
+
# Re-expressed through the cn-* theme layer. flex-wrap stays
|
|
7
|
+
# poetry's one structural addition (long lists wrap in the real flex
|
|
8
|
+
# column); order-first stays inline in the top align (layout
|
|
9
|
+
# mechanism - the padding rides the theme rule). The toggle's
|
|
10
|
+
# data-hidden dim is live-controller state, inline.
|
|
11
|
+
class Style < Poetry::Core::Style
|
|
12
|
+
base "cn-chart-legend flex flex-wrap items-center justify-center"
|
|
13
|
+
|
|
14
|
+
variant :align, {
|
|
15
|
+
top: "cn-chart-legend-align-top order-first",
|
|
16
|
+
bottom: "cn-chart-legend-align-bottom"
|
|
17
|
+
}
|
|
18
|
+
element :item, "cn-chart-legend-item flex items-center"
|
|
19
|
+
element :swatch, "cn-chart-legend-swatch shrink-0"
|
|
20
|
+
# Toggle buttons dim when their series hides (the live controller
|
|
21
|
+
# stamps data-hidden).
|
|
22
|
+
element :toggle, "cursor-pointer data-hidden:opacity-40"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
<% if grid? %>
|
|
5
|
+
<g data-slot="chart-grid" aria-hidden="true">
|
|
6
|
+
<% if grid_config.horizontal %>
|
|
7
|
+
<% cartesian.y_ticks.each do |tick| %>
|
|
8
|
+
<line class="<%= css(:grid_line) %>" x1="<%= fnum(cartesian.plot_left) %>" x2="<%= fnum(cartesian.plot_right) %>" y1="<%= fnum(cartesian.y_scale.call(tick)) %>" y2="<%= fnum(cartesian.y_scale.call(tick)) %>" />
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|
|
11
|
+
<% if grid_config.vertical %>
|
|
12
|
+
<% cartesian.x_centers.each do |x| %>
|
|
13
|
+
<line class="<%= css(:grid_line) %>" x1="<%= fnum(x) %>" x2="<%= fnum(x) %>" y1="<%= fnum(cartesian.plot_top) %>" y2="<%= fnum(cartesian.plot_bottom) %>" />
|
|
14
|
+
<% end %>
|
|
15
|
+
<% end %>
|
|
16
|
+
</g>
|
|
17
|
+
<% end %>
|
|
18
|
+
<%= cursor_svg(:line) %>
|
|
19
|
+
<g data-slot="chart-lines">
|
|
20
|
+
<% series_entries.each do |entry| %>
|
|
21
|
+
<% if (d = series_path(entry)) %>
|
|
22
|
+
<%# pathLength="1" normalizes the path to unit length - the motion stylesheet's dash draw-in needs no measurement. %>
|
|
23
|
+
<path data-slot="chart-line" data-key="<%= entry.key %>" d="<%= d %>" fill="none" stroke="var(--color-<%= entry.key %>)" stroke-width="<%= entry.stroke_width %>"<% if animate? %> pathLength="1"<% end %> />
|
|
24
|
+
<% end %>
|
|
25
|
+
<% if entry.dots %>
|
|
26
|
+
<g data-slot="chart-dots" data-key="<%= entry.key %>">
|
|
27
|
+
<% markers(entry).each do |marker| %>
|
|
28
|
+
<circle data-slot="chart-dot" cx="<%= fnum(marker[:x]) %>" cy="<%= fnum(marker[:y]) %>" r="<%= entry.dot_radius %>" fill="<%= dot_fill(entry, marker) %>" />
|
|
29
|
+
<% end %>
|
|
30
|
+
</g>
|
|
31
|
+
<% end %>
|
|
32
|
+
<% if entry.error_key %>
|
|
33
|
+
<g data-slot="chart-error-bars" data-key="<%= entry.key %>" aria-hidden="true">
|
|
34
|
+
<% markers(entry).each do |marker| %>
|
|
35
|
+
<% range = error_range(marker[:row], entry.error_key, marker[:value]) %>
|
|
36
|
+
<% if range %>
|
|
37
|
+
<path class="<%= css(:error_bar) %>" d="<%= error_bar_path(marker[:x], cartesian.y_scale.call(range[0]), cartesian.y_scale.call(range[1]), entry.error_width) %>" fill="none" />
|
|
38
|
+
<% end %>
|
|
39
|
+
<% end %>
|
|
40
|
+
</g>
|
|
41
|
+
<% end %>
|
|
42
|
+
<% if entry.labels %>
|
|
43
|
+
<g data-slot="chart-labels" data-key="<%= entry.key %>">
|
|
44
|
+
<% markers(entry).each do |marker| %>
|
|
45
|
+
<text class="<%= css(:label) %>" x="<%= fnum(marker[:x]) %>" y="<%= fnum(marker[:y] - 12) %>" text-anchor="middle"><%= marker_label(marker) %></text>
|
|
46
|
+
<% end %>
|
|
47
|
+
</g>
|
|
48
|
+
<% end %>
|
|
49
|
+
<% end %>
|
|
50
|
+
</g>
|
|
51
|
+
<%= reference_marks_svg %>
|
|
52
|
+
<% if tooltip? %>
|
|
53
|
+
<g data-slot="chart-active-dots" aria-hidden="true">
|
|
54
|
+
<% series_entries.each do |entry| %>
|
|
55
|
+
<% active_dot_markers(entry).each do |marker| %>
|
|
56
|
+
<circle data-slot="chart-active-dot" data-key="<%= entry.key %>" data-index="<%= marker[:index] %>" cx="<%= fnum(marker[:x]) %>" cy="<%= fnum(marker[:y]) %>" r="4" fill="var(--color-<%= entry.key %>)" display="none" />
|
|
57
|
+
<% end %>
|
|
58
|
+
<% end %>
|
|
59
|
+
</g>
|
|
60
|
+
<% end %>
|
|
61
|
+
<% if x_axis? %>
|
|
62
|
+
<g data-slot="chart-x-axis">
|
|
63
|
+
<% cartesian.categories.each_with_index do |category, i| %>
|
|
64
|
+
<text class="<%= css(:tick) %>" x="<%= fnum(cartesian.x_centers[i]) %>" y="<%= fnum(cartesian.plot_bottom + x_axis_config.tick_margin) %>" dy="0.71em" text-anchor="middle"><%= x_tick_label(category) %></text>
|
|
65
|
+
<% end %>
|
|
66
|
+
</g>
|
|
67
|
+
<% end %>
|
|
68
|
+
<% if y_axis? %>
|
|
69
|
+
<g data-slot="chart-y-axis">
|
|
70
|
+
<% cartesian.y_ticks.each do |tick| %>
|
|
71
|
+
<text class="<%= css(:tick) %>" x="<%= fnum(cartesian.plot_left - y_axis_config.tick_margin) %>" y="<%= fnum(cartesian.y_scale.call(tick)) %>" dy="0.355em" text-anchor="end"><%= y_tick_label(tick) %></text>
|
|
72
|
+
<% end %>
|
|
73
|
+
</g>
|
|
74
|
+
<% end %>
|
|
75
|
+
<%= brush_svg %>
|
|
76
|
+
<%= zoom_selection_svg %>
|
|
77
|
+
</svg>
|
|
78
|
+
<% if legend? %>
|
|
79
|
+
<%= render legend_component %>
|
|
80
|
+
<% end %>
|
|
81
|
+
<% if tooltip? %><%= render tooltip_layer_component %><% end %>
|
|
82
|
+
<script type="application/json" data-slot="chart-coordinates" <%= tag.attributes(stimulus_attributes_for(:coordinates)) %>><%= script_json(coordinates_json) %></script>
|
|
83
|
+
<% if live? %><script type="application/json" data-slot="chart-live-payload" <%= tag.attributes(stimulus_attributes_for(:live_payload)) %>><%= script_json(live_payload_json) %></script>
|
|
84
|
+
<% end %> <% end %>
|
|
85
|
+
<% end %>
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Charts
|
|
5
|
+
# The line chart family.
|
|
6
|
+
module LineChart
|
|
7
|
+
# Renders a line chart - one stroked curve per series, no fills -
|
|
8
|
+
# through the cartesian pipeline. Adds dot variants (solid
|
|
9
|
+
# series-colored dots, or per-point colors read from a data key),
|
|
10
|
+
# value labels stamped above the points, and error whiskers.
|
|
11
|
+
#
|
|
12
|
+
# @example Two series over a formatted month axis
|
|
13
|
+
# <%= poetry_chart :line, data:, config:, margin: { left: 12, right: 12 } do |c| %>
|
|
14
|
+
# <% c.with_grid %>
|
|
15
|
+
# <% c.with_x_axis data_key: :month, tick_formatter: ->(v) { v[0, 3] } %>
|
|
16
|
+
# <% c.with_line data_key: :desktop %>
|
|
17
|
+
# <% c.with_line data_key: :mobile %>
|
|
18
|
+
# <% end %>
|
|
19
|
+
class Component < Poetry::Core::Component
|
|
20
|
+
include Poetry::Charts::ChartFamily
|
|
21
|
+
include Poetry::Charts::TooltipWiring
|
|
22
|
+
include Poetry::Charts::Motion
|
|
23
|
+
include Poetry::Charts::Live
|
|
24
|
+
include Poetry::Charts::ReferenceMarks
|
|
25
|
+
include Poetry::Charts::ErrorBars
|
|
26
|
+
|
|
27
|
+
# Projected into the registry and agent surface.
|
|
28
|
+
AGENT_RULES = [
|
|
29
|
+
"Compose from slots: with_grid / with_x_axis(data_key:) / with_line(data_key:) / with_legend.",
|
|
30
|
+
"Lines default to stroke-width 2 and NO dots (the ported block look); dots: true adds them.",
|
|
31
|
+
"dot_color_key: reads a per-row data key for per-point dot colors (the dots-colors block).",
|
|
32
|
+
"labels: true stamps each value above its point; give the chart margin top when using it.",
|
|
33
|
+
"Colors come from the config - never set stroke on a line directly.",
|
|
34
|
+
"Entrance animation is on by default (source parity); animate: false for a static chart. " \
|
|
35
|
+
"Reduced-motion users always get the finished chart."
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# One with_line call's captured series config.
|
|
39
|
+
Series = Data.define(:key, :curve, :stroke_width, :dots, :dot_radius, :dot_color_key, :labels,
|
|
40
|
+
:error_key, :error_width) do
|
|
41
|
+
# The cartesian pipeline contract (lines never stack).
|
|
42
|
+
def stack = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
option :data, ActiveModel::Type::Value.new, required: true,
|
|
46
|
+
doc: "The rows to plot: an array of hashes, one per x category."
|
|
47
|
+
option :config, ActiveModel::Type::Value.new, required: true,
|
|
48
|
+
doc: "The series config - key => { label:, color: } - naming " \
|
|
49
|
+
"and coloring every series."
|
|
50
|
+
option :id, :string,
|
|
51
|
+
doc: "Explicit DOM id token, stable across renders; otherwise the chart gets a unique per-render id."
|
|
52
|
+
option :width, :integer, default: 640,
|
|
53
|
+
doc: "ViewBox width in pixels; the rendered chart scales to its container."
|
|
54
|
+
option :height, :integer, default: 360, doc: "ViewBox height in pixels."
|
|
55
|
+
option :margin, ActiveModel::Type::Value.new,
|
|
56
|
+
doc: "Plot margin overrides ({ top:, right:, bottom:, left: }), merged over the defaults."
|
|
57
|
+
option :label, :string,
|
|
58
|
+
doc: "Accessible name for the chart SVG; defaults to one built from the configured series."
|
|
59
|
+
|
|
60
|
+
motion_options
|
|
61
|
+
live_option
|
|
62
|
+
|
|
63
|
+
part "chart-svg", "The chart canvas (<svg>) - server-computed geometry in a fixed viewBox; " \
|
|
64
|
+
"role=img, or the focusable role=application accessibilityLayer when the " \
|
|
65
|
+
"tooltip attaches",
|
|
66
|
+
states: {
|
|
67
|
+
"data-animate" => "present when animate (the default) - the motion stylesheet and " \
|
|
68
|
+
"controller key the entrance off it",
|
|
69
|
+
"data-motion" => { condition: "runtime - the motion rig stamps the animation " \
|
|
70
|
+
"lifecycle (entrance/morph, then settled)",
|
|
71
|
+
values: %w[entrance morph settled] }
|
|
72
|
+
},
|
|
73
|
+
vars: {
|
|
74
|
+
"--poetry-motion-duration" => "the entrance/morph duration (animation_duration, ms)",
|
|
75
|
+
"--poetry-motion-easing" => "the animation easing keyword (animation_easing)",
|
|
76
|
+
"--poetry-motion-delay" => "the pre-animation hold (animation_begin, ms)"
|
|
77
|
+
}
|
|
78
|
+
part "chart-grid", "The gridline group (with_grid) - horizontal and/or vertical rules " \
|
|
79
|
+
"across the plot"
|
|
80
|
+
part "chart-cursor", "The hover cursor, hidden until the tooltip controller positions " \
|
|
81
|
+
"and reveals it at the active index - a vertical rule or a " \
|
|
82
|
+
"translucent band rect (bar charts)"
|
|
83
|
+
part "chart-lines", "The line-mark group - each series' curve plus its companion marks"
|
|
84
|
+
part "chart-line", "One series' stroked curve - pathLength=1 when animating so the " \
|
|
85
|
+
"dash draw-in needs no measurement",
|
|
86
|
+
states: { "data-key" => "the series key" }
|
|
87
|
+
part "chart-dots", "One series' point-dot group (dots: true)",
|
|
88
|
+
states: { "data-key" => "the series key" }
|
|
89
|
+
part "chart-dot", "One point dot (<circle>)"
|
|
90
|
+
part "chart-error-bars", "One series' error-whisker group (error_key:) - cap-stem-cap " \
|
|
91
|
+
"paths in the foreground color",
|
|
92
|
+
states: { "data-key" => "the series key" }
|
|
93
|
+
part "chart-labels", "One series' value-label group (labels: true)",
|
|
94
|
+
states: { "data-key" => "the series key" }
|
|
95
|
+
part "chart-reference", "The reference-mark group (with_reference_line/_area/_dot), " \
|
|
96
|
+
"painted above the series"
|
|
97
|
+
part "chart-active-dots", "The hover-marker group (with_tooltip) - pre-rendered hidden " \
|
|
98
|
+
"circles for every series x index"
|
|
99
|
+
part "chart-active-dot", "One hover marker - display=none until the tooltip controller " \
|
|
100
|
+
"reveals the active index's dot",
|
|
101
|
+
states: {
|
|
102
|
+
"data-key" => "the series key",
|
|
103
|
+
"data-index" => "the datum index",
|
|
104
|
+
"data-active" => "runtime - rides the marker while its index is the active one"
|
|
105
|
+
}
|
|
106
|
+
part "chart-x-axis", "The x-axis tick-label group (with_x_axis)"
|
|
107
|
+
part "chart-coordinates", "The embedded per-index geometry payload " \
|
|
108
|
+
"(<script type=application/json>) the tooltip controller " \
|
|
109
|
+
"reads - zero chart math in the browser"
|
|
110
|
+
|
|
111
|
+
renders_many :lines,
|
|
112
|
+
doc: "A line series bound to data_key:. dots: marks each point; dot_color_key: reads per-point " \
|
|
113
|
+
"dot colors from the row; labels: stamps each value above its point; error_key: adds " \
|
|
114
|
+
"error whiskers.",
|
|
115
|
+
renders: lambda { |data_key:, curve: :natural, stroke_width: 2, dots: false,
|
|
116
|
+
dot_radius: 3, dot_color_key: nil, labels: false, error_key: nil, error_width: 5|
|
|
117
|
+
raise ArgumentError, "unknown curve #{curve.inspect}" unless CURVES.include?(curve.to_sym)
|
|
118
|
+
|
|
119
|
+
(@series_entries ||= []) << Series.new(key: data_key.to_s, curve: curve.to_sym, stroke_width:,
|
|
120
|
+
dots:, dot_radius:, dot_color_key: dot_color_key&.to_s,
|
|
121
|
+
labels:, error_key: error_key&.to_s, error_width:)
|
|
122
|
+
nil
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
include Poetry::Charts::CartesianFamily
|
|
126
|
+
|
|
127
|
+
# The captured Series configs, forcing lazy slot evaluation.
|
|
128
|
+
# @api private
|
|
129
|
+
def series_entries
|
|
130
|
+
lines? # force slot evaluation (slots evaluate lazily)
|
|
131
|
+
@series_entries ||= []
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The chart's cartesian geometry, built once per render.
|
|
135
|
+
# @api private
|
|
136
|
+
def cartesian
|
|
137
|
+
@cartesian ||= Cartesian.new(
|
|
138
|
+
data: data,
|
|
139
|
+
series: series_entries,
|
|
140
|
+
width: width,
|
|
141
|
+
height: height,
|
|
142
|
+
x_key: x_axis_config&.data_key,
|
|
143
|
+
margin: live_margin,
|
|
144
|
+
category_axis: x_axis?,
|
|
145
|
+
value_axis: y_axis?,
|
|
146
|
+
y_tick_count: y_axis_config&.tick_count || 5
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# One series' stroked curve path.
|
|
151
|
+
# @api private
|
|
152
|
+
def series_path(entry)
|
|
153
|
+
points = cartesian.points(entry)
|
|
154
|
+
Geometry::Line.new(
|
|
155
|
+
x: ->(p, _i) { p[:x] },
|
|
156
|
+
y: ->(p, _i) { p[:y1] },
|
|
157
|
+
curve: entry.curve,
|
|
158
|
+
defined: ->(p, _i) { !p[:value].nan? }
|
|
159
|
+
).path(points)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The visible points for dots/labels (NaN values render nothing).
|
|
163
|
+
# @api private
|
|
164
|
+
def markers(entry)
|
|
165
|
+
cartesian.points(entry).each_with_index.filter_map do |point, i|
|
|
166
|
+
next if point[:value].nan?
|
|
167
|
+
|
|
168
|
+
{ x: point[:x], y: point[:y1], value: point[:value], row: data[i] }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Per-point dot color: the dot_color_key row value (CSS-validated),
|
|
173
|
+
# else the series color.
|
|
174
|
+
# @api private
|
|
175
|
+
def dot_fill(entry, marker)
|
|
176
|
+
if entry.dot_color_key
|
|
177
|
+
color = marker[:row].to_h.transform_keys(&:to_s)[entry.dot_color_key].to_s
|
|
178
|
+
raise ArgumentError, "dot color #{color.inspect} is not CSS-safe" unless color.match?(Config::COLOR)
|
|
179
|
+
|
|
180
|
+
color
|
|
181
|
+
else
|
|
182
|
+
"var(--color-#{entry.key})"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# One x tick's label, through the slot's formatter when given.
|
|
187
|
+
# @api private
|
|
188
|
+
def x_tick_label(category)
|
|
189
|
+
formatter = x_axis_config&.tick_formatter
|
|
190
|
+
formatter ? formatter.call(category).to_s : category.to_s
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# One y tick's label, through the slot's formatter when given.
|
|
194
|
+
# @api private
|
|
195
|
+
def y_tick_label(tick)
|
|
196
|
+
formatter = y_axis_config&.tick_formatter
|
|
197
|
+
formatter ? formatter.call(tick).to_s : Geometry.js_number(tick.to_f)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# A point's value label, integers shown bare.
|
|
201
|
+
# @api private
|
|
202
|
+
def marker_label(marker)
|
|
203
|
+
value = marker[:value]
|
|
204
|
+
value == value.to_i ? value.to_i.to_s : value.to_s
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# ChartFamily#svg_label's chart-type lead-in.
|
|
208
|
+
# @api private
|
|
209
|
+
def svg_label_prefix = "Line chart"
|
|
210
|
+
|
|
211
|
+
# The embedded per-index geometry payload the tooltip controller
|
|
212
|
+
# reads.
|
|
213
|
+
# @api private
|
|
214
|
+
def coordinates_json
|
|
215
|
+
cartesian.coordinates.to_json
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# -- live mode ---------------------------------------------------
|
|
219
|
+
|
|
220
|
+
# The chart type in the live spec.
|
|
221
|
+
# @api private
|
|
222
|
+
def live_type = :line
|
|
223
|
+
|
|
224
|
+
# The series list serialized into the live spec.
|
|
225
|
+
# @api private
|
|
226
|
+
def live_series
|
|
227
|
+
series_entries.map { |e| { data_key: e.key, curve: e.curve } }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The axis config serialized into the live spec.
|
|
231
|
+
# @api private
|
|
232
|
+
def live_axes
|
|
233
|
+
axes = {}
|
|
234
|
+
axes[:x] = { data_key: x_axis_config.data_key } if x_axis_config&.data_key
|
|
235
|
+
axes[:y] = { tick_count: y_axis_config.tick_count } if y_axis_config
|
|
236
|
+
axes
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The x scale kind the live renderer rebuilds.
|
|
240
|
+
# @api private
|
|
241
|
+
def live_x_scale_type = :point
|
|
242
|
+
# Whether the live frame renders a category axis.
|
|
243
|
+
# @api private
|
|
244
|
+
def live_category_axis? = x_axis?
|
|
245
|
+
|
|
246
|
+
private :cartesian, :series_path, :markers, :dot_fill, :x_tick_label, :y_tick_label
|
|
247
|
+
private :marker_label, :svg_label_prefix, :coordinates_json, :live_type, :live_series, :live_axes
|
|
248
|
+
private :live_x_scale_type, :live_category_axis?
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|