trevl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/NOTICE +9 -0
  4. data/README.md +431 -0
  5. data/Rakefile +10 -0
  6. data/assets/trendence-logo.svg +17 -0
  7. data/assets/trevl-logo.svg +20 -0
  8. data/docs/screenshots/01_bar_chart.png +0 -0
  9. data/docs/screenshots/02_column_chart.png +0 -0
  10. data/docs/screenshots/03_line_chart.png +0 -0
  11. data/docs/screenshots/04_pie_chart.png +0 -0
  12. data/docs/screenshots/05_computed_fields.png +0 -0
  13. data/docs/screenshots/06_postprocess_top_n.png +0 -0
  14. data/docs/screenshots/10_multi_series.png +0 -0
  15. data/examples/01_bar_chart.yml +16 -0
  16. data/examples/02_column_chart.yml +19 -0
  17. data/examples/03_line_chart.yml +21 -0
  18. data/examples/04_pie_chart.yml +15 -0
  19. data/examples/05_computed_fields.yml +26 -0
  20. data/examples/06_postprocess_top_n.yml +21 -0
  21. data/examples/07_score_kpi.yml +13 -0
  22. data/examples/08_table.yml +14 -0
  23. data/examples/09_template_inheritance.yml +25 -0
  24. data/examples/10_multi_series.yml +23 -0
  25. data/lib/trevl/auth/bearer_token.rb +23 -0
  26. data/lib/trevl/configuration.rb +24 -0
  27. data/lib/trevl/core_ext/hash.rb +37 -0
  28. data/lib/trevl/data_source/api.rb +86 -0
  29. data/lib/trevl/data_source/base.rb +19 -0
  30. data/lib/trevl/data_source/cube.rb +70 -0
  31. data/lib/trevl/data_source/static.rb +30 -0
  32. data/lib/trevl/data_source.rb +38 -0
  33. data/lib/trevl/errors.rb +9 -0
  34. data/lib/trevl/highcharts_asset.rb +45 -0
  35. data/lib/trevl/html_renderer.rb +91 -0
  36. data/lib/trevl/notebook/display.rb +53 -0
  37. data/lib/trevl/notebook.rb +41 -0
  38. data/lib/trevl/processor.rb +106 -0
  39. data/lib/trevl/renderer/data_fetcher.rb +63 -0
  40. data/lib/trevl/renderer/ref_parser.rb +61 -0
  41. data/lib/trevl/renderer/transform.rb +104 -0
  42. data/lib/trevl/renderer.rb +345 -0
  43. data/lib/trevl/schema/component.json +337 -0
  44. data/lib/trevl/schema/document.json +41 -0
  45. data/lib/trevl/template_store.rb +38 -0
  46. data/lib/trevl/validator.rb +96 -0
  47. data/lib/trevl/version.rb +5 -0
  48. data/lib/trevl.rb +99 -0
  49. data/llms.txt +171 -0
  50. data/notebooks/demo.ipynb +312 -0
  51. data/notebooks/exploration.ipynb +164 -0
  52. data/scripts/generate_screenshots.rb +107 -0
  53. metadata +137 -0
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trevl
4
+ class Processor
5
+ attr_reader :trevl_content, :processed_content, :parse_errors
6
+
7
+ def initialize(trevl_content)
8
+ @trevl_content = trevl_content
9
+ @parse_errors = []
10
+ @processed_content = process_content
11
+ end
12
+
13
+ def self.from_hash(trevl_hash)
14
+ new(trevl_hash.to_yaml)
15
+ end
16
+
17
+ def components
18
+ @processed_content["components"]
19
+ end
20
+
21
+ def component_yaml_content
22
+ comps = @processed_content["components"]
23
+ stringified = if comps.is_a?(Array)
24
+ comps.map { |c| c.is_a?(Hash) ? CoreExt::Hash.deep_stringify_keys(c) : c }
25
+ else
26
+ comps
27
+ end
28
+ YAML.dump(stringified, canonical: false)
29
+ end
30
+
31
+ def full_yaml_content
32
+ YAML.dump(CoreExt::Hash.deep_stringify_keys(@processed_content), canonical: false)
33
+ end
34
+
35
+ def valid?
36
+ @parse_errors.empty?
37
+ end
38
+
39
+ private
40
+
41
+ def process_content
42
+ yaml_content = begin
43
+ YAML.safe_load(trevl_content, permitted_classes: [Hash, Symbol], aliases: true)
44
+ rescue Psych::SyntaxError => e
45
+ @parse_errors << "Error parsing TREVL YAML: #{e.message}"
46
+ raise ParseError, "Error parsing TREVL YAML: #{e.message}"
47
+ end
48
+
49
+ unless yaml_content.is_a?(Hash) || yaml_content.is_a?(Array)
50
+ @parse_errors << "TREVL content must be a Hash or Array"
51
+ raise ParseError, "TREVL content must be a Hash or Array"
52
+ end
53
+
54
+ # Support both top-level hash with "components" key and bare array of components
55
+ if yaml_content.is_a?(Array)
56
+ yaml_content = {"components" => yaml_content}
57
+ end
58
+
59
+ if yaml_content["components"].is_a?(Array)
60
+ yaml_content["components"] = apply_templates(yaml_content["components"])
61
+ end
62
+
63
+ yaml_content
64
+ rescue ParseError
65
+ raise
66
+ rescue => e
67
+ @parse_errors << "Error processing TREVL content: #{e.message}"
68
+ raise ParseError, "Error processing TREVL content: #{e.message}"
69
+ end
70
+
71
+ def apply_templates(components)
72
+ components.map { |component| apply_template_to_component(component) }
73
+ end
74
+
75
+ def apply_template_to_component(component)
76
+ return component unless component.is_a?(Hash) && component["template"]
77
+
78
+ template_name = component["template"]
79
+ template_hash = Trevl.template_store.find(template_name)
80
+ return component unless template_hash
81
+
82
+ deep_merge_with_overwrite(template_hash, component.except("template"))
83
+ end
84
+
85
+ def deep_merge_with_overwrite(template_hash, component_hash)
86
+ return component_hash unless template_hash.is_a?(Hash)
87
+
88
+ result = {}
89
+
90
+ component_hash.each do |key, component_val|
91
+ result[key] = if template_hash.key?(key) && template_hash[key].is_a?(Hash) && component_val.is_a?(Hash)
92
+ deep_merge_with_overwrite(template_hash[key], component_val)
93
+ else
94
+ component_val
95
+ end
96
+ end
97
+
98
+ template_hash.each do |key, template_val|
99
+ next if component_hash.key?(key)
100
+ result[key] = template_val
101
+ end
102
+
103
+ result
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trevl
4
+ class Renderer
5
+ # Normalizes API responses and resolves API parameters.
6
+ #
7
+ # fetcher = DataFetcher.new
8
+ # fetcher.coerce_payload([{x: 1}])
9
+ # # => {"data" => [{"x" => 1}], "meta" => {}}
10
+ #
11
+ class DataFetcher
12
+ def coerce_payload(result)
13
+ case result
14
+ when Array
15
+ {"data" => result, "meta" => {}}
16
+ when Hash
17
+ data = result["data"] || result[:data]
18
+ meta = result["meta"] || result[:meta] || {}
19
+ meta = {} unless meta.is_a?(Hash)
20
+ if data.is_a?(Array)
21
+ {"data" => data, "meta" => CoreExt::Hash.deep_stringify_keys(meta)}
22
+ else
23
+ {"data" => [], "meta" => CoreExt::Hash.deep_stringify_keys(meta)}
24
+ end
25
+ else
26
+ {"data" => [], "meta" => {}}
27
+ end
28
+ end
29
+
30
+ def merge_api_params(component, query_params)
31
+ api_params = component["api_parameters"] || component["api-parameters"] || {}
32
+ api_params = CoreExt::Hash.deep_stringify_keys(api_params) if api_params.is_a?(Hash)
33
+ api_params = resolve_placeholders(api_params, query_params)
34
+
35
+ overrides = query_params["param_override"] || query_params[:param_override]
36
+ if overrides.is_a?(Hash)
37
+ overrides = CoreExt::Hash.deep_stringify_keys(overrides)
38
+ api_params = CoreExt::Hash.deep_merge(api_params, overrides)
39
+ end
40
+
41
+ api_params
42
+ end
43
+
44
+ private
45
+
46
+ def resolve_placeholders(params, query_params)
47
+ params.each_with_object({}) do |(key, value), result|
48
+ if value.is_a?(String) && value.start_with?("$")
49
+ param_key = value.delete_prefix("$")
50
+ raw = query_params[param_key] || query_params[param_key.to_sym]
51
+ resolved = raw.is_a?(Array) ? raw.first : raw
52
+ result[key] = resolved unless resolved.nil?
53
+ elsif value.is_a?(Hash)
54
+ resolved = resolve_placeholders(value, query_params)
55
+ result[key] = resolved unless resolved.empty?
56
+ else
57
+ result[key] = value
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trevl
4
+ class Renderer
5
+ # Parses $-prefixed variable references in TREVL YAML.
6
+ #
7
+ # parser = RefParser.new
8
+ # parser.parse("salary.data.q50")
9
+ # # => {resource: nil, endpoint: "salary", scope: :data, keys: ["q50"]}
10
+ #
11
+ class RefParser
12
+ def parse(ref_without_dollar)
13
+ parts = ref_without_dollar.to_s.split(".")
14
+ resource = nil
15
+
16
+ if parts.length >= 4 && %w[data meta].include?(parts[2])
17
+ resource = parts.shift
18
+ end
19
+
20
+ endpoint = parts[0].to_s
21
+ return {resource: resource, endpoint: endpoint, scope: :data, keys: [], legacy: true} if parts.length < 2
22
+
23
+ case parts[1]
24
+ when "data"
25
+ {resource: resource, endpoint: endpoint, scope: :data, keys: parts[2..] || [], legacy: false}
26
+ when "meta"
27
+ {resource: resource, endpoint: endpoint, scope: :meta, keys: parts[2..] || [], legacy: false}
28
+ else
29
+ {resource: resource, endpoint: endpoint, scope: :data, keys: [parts.last], legacy: true}
30
+ end
31
+ end
32
+
33
+ def ref_key(parsed)
34
+ [parsed[:resource], parsed[:endpoint]].compact.join("/")
35
+ end
36
+
37
+ def ref_key_for(ref_without_dollar)
38
+ ref_key(parse(ref_without_dollar))
39
+ end
40
+
41
+ def extract_references(obj)
42
+ refs = []
43
+ case obj
44
+ when Hash then obj.each_value { |v| refs.concat(extract_references(v)) }
45
+ when Array then obj.each { |v| refs.concat(extract_references(v)) }
46
+ when String then refs << obj.delete_prefix("$") if obj.start_with?("$")
47
+ end
48
+ refs.uniq
49
+ end
50
+
51
+ def dig(obj, keys)
52
+ return obj if keys.nil? || keys.empty?
53
+ keys.reduce(obj) do |acc, key|
54
+ return nil if acc.nil?
55
+ next nil unless acc.is_a?(Hash)
56
+ acc[key.to_s] || acc[key.to_sym]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "execjs"
4
+
5
+ module Trevl
6
+ class Renderer
7
+ # Applies computed fields and postprocess transformations to data rows.
8
+ #
9
+ # transform = Transform.new(ref_parser)
10
+ # transform.apply_computed_fields(rows, payloads, component)
11
+ #
12
+ class Transform
13
+ def initialize(ref_parser)
14
+ @refs = ref_parser
15
+ end
16
+
17
+ # Applies computed fields (per-row JavaScript expressions) to all rows.
18
+ def apply_computed_fields(rows_by_ref_key, payload_by_ref_key, component)
19
+ computeds = collect_computed_fields(component)
20
+ return rows_by_ref_key unless computeds.any?
21
+
22
+ rows_by_ref_key.each do |rk, rows|
23
+ rows.each do |row|
24
+ computeds.each do |computed|
25
+ arg_names, arg_values = resolve_arguments(computed, row, rk, payload_by_ref_key)
26
+ js = "(function(#{arg_names.join(", ")}) { return #{computed["code"]} })(#{arg_values.map(&:to_json).join(", ")})"
27
+ row[computed["name"]] = ExecJS.eval(js)
28
+ end
29
+ end
30
+ end
31
+
32
+ rows_by_ref_key
33
+ end
34
+
35
+ # Applies postprocess JavaScript to the full dataset.
36
+ def apply_postprocess(rows_by_ref_key, component, warnings)
37
+ code = component["postprocess"]
38
+ return rows_by_ref_key unless code.is_a?(String) && !code.strip.empty?
39
+
40
+ rows_by_ref_key.each do |rk, rows|
41
+ js = "(function() { var $result = #{rows.to_json}; #{code.strip.chomp(";")}; return $result; })()"
42
+ begin
43
+ result = ExecJS.eval(js)
44
+ if result.is_a?(Array)
45
+ rows_by_ref_key[rk] = result
46
+ else
47
+ warnings << "Postprocess for '#{component["id"]}' returned #{result.class} instead of Array"
48
+ Trevl.logger.warn("[Trevl::Renderer] Postprocess returned non-Array for '#{component["id"]}'")
49
+ end
50
+ rescue ExecJS::Error => e
51
+ raise RenderError, "Postprocess JavaScript error for '#{component["id"]}': #{e.message}"
52
+ end
53
+ end
54
+
55
+ rows_by_ref_key
56
+ end
57
+
58
+ private
59
+
60
+ def collect_computed_fields(component)
61
+ computeds = Array(component["computed"])
62
+
63
+ if component["queries"].is_a?(Array)
64
+ existing_names = computeds.map { |c| c["name"] }.compact
65
+ component["queries"].each do |q|
66
+ Array(q["computed"]).each do |c|
67
+ computeds << c unless existing_names.include?(c["name"])
68
+ end
69
+ end
70
+ end
71
+
72
+ computeds
73
+ end
74
+
75
+ def resolve_arguments(computed, row, rk, payload_by_ref_key)
76
+ args = computed["arguments"] || {}
77
+ arg_names = []
78
+ arg_values = []
79
+
80
+ args.each do |name, ref|
81
+ arg_names << name
82
+ if ref.is_a?(String) && ref.start_with?("$")
83
+ p = @refs.parse(ref.delete_prefix("$"))
84
+ p_key = @refs.ref_key(p)
85
+ ep_meta = (payload_by_ref_key[p_key] || {})["meta"] || {}
86
+ val = if p[:scope] == :meta
87
+ @refs.dig(ep_meta, p[:keys])
88
+ elsif p_key == rk
89
+ @refs.dig(row, p[:keys])
90
+ else
91
+ first = payload_by_ref_key[p_key]&.dig("data")&.first
92
+ @refs.dig(first, p[:keys])
93
+ end
94
+ arg_values << val
95
+ else
96
+ arg_values << ref
97
+ end
98
+ end
99
+
100
+ [arg_names, arg_values]
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "renderer/ref_parser"
4
+ require_relative "renderer/data_fetcher"
5
+ require_relative "renderer/transform"
6
+
7
+ module Trevl
8
+ class Renderer
9
+ attr_reader :component, :query_params, :data_sources, :inline_source, :raw_data, :warnings
10
+
11
+ def initialize(component_hash, query_params = {}, data_sources: {}, data: nil)
12
+ @component = component_hash
13
+ @query_params = query_params || {}
14
+ @data_sources = normalize_data_sources(data_sources)
15
+ @inline_source = data ? DataSource::Static.new(data: data) : nil
16
+ @raw_data = {}
17
+ @warnings = []
18
+ @refs = RefParser.new
19
+ @fetcher = DataFetcher.new
20
+ @transform = Transform.new(@refs)
21
+ end
22
+
23
+ def render
24
+ case component["type"]
25
+ when "text" then render_text
26
+ when "filter" then has_api? ? render_filter : nil
27
+ when "table" then has_api? ? render_table : nil
28
+ when "score" then has_api? ? render_score : nil
29
+ when "chart" then has_api? ? render_chart : nil
30
+ end
31
+ end
32
+
33
+ # Public class methods kept for backward compatibility and testing
34
+ def self.parse_trevl_ref(ref) = RefParser.new.parse(ref)
35
+ def self.coerce_fetch_payload(result) = DataFetcher.new.coerce_payload(result)
36
+ def self.deep_dig_indifferent(obj, keys) = RefParser.new.dig(obj, keys)
37
+
38
+ private
39
+
40
+ def has_api?
41
+ api_name = component["api"]
42
+ return true if inline_source
43
+ !api_name.nil? && !api_name.to_s.strip.empty?
44
+ end
45
+
46
+ # Resolution order per component: explicitly injected data_sources win,
47
+ # then inline data (when given, it answers any api name and components
48
+ # without one), then the global registry. Injected and inline sources
49
+ # never touch shared state (thread-safe by construction).
50
+ def data_source
51
+ @data_source ||= data_sources[normalize_source_name(component["api"])] ||
52
+ inline_source ||
53
+ DataSource.for(component["api"])
54
+ end
55
+
56
+ def normalize_data_sources(sources)
57
+ (sources || {}).each_with_object({}) do |(name, source), normalized|
58
+ normalized[normalize_source_name(name)] = source.is_a?(Class) ? source.new : source
59
+ end
60
+ end
61
+
62
+ def normalize_source_name(name)
63
+ name.to_s.strip.downcase
64
+ end
65
+
66
+ def request_params
67
+ @request_params ||= @fetcher.merge_api_params(component, query_params)
68
+ end
69
+
70
+ # --- Data pipeline ---
71
+
72
+ def fetch_and_prepare(references_source)
73
+ references = @refs.extract_references(references_source)
74
+ parsed_refs = references.map { |ref| @refs.parse(ref) }
75
+ ref_keys = parsed_refs.map { |p| @refs.ref_key(p) }.uniq
76
+
77
+ payload_by_ref_key = {}
78
+ ref_keys.each do |key|
79
+ parsed = parsed_refs.find { |p| @refs.ref_key(p) == key }
80
+ payload_by_ref_key[key] = @fetcher.coerce_payload(
81
+ data_source.fetch(parsed[:endpoint], request_params, resource: parsed[:resource])
82
+ )
83
+ warn_no_data(key) if payload_by_ref_key[key]["data"].empty?
84
+ end
85
+
86
+ @raw_data = payload_by_ref_key.transform_values { |p| CoreExt::Hash.deep_dup(p) }
87
+ payload_by_ref_key
88
+ end
89
+
90
+ def transform_rows(payload_by_ref_key)
91
+ rows_by_ref_key = payload_by_ref_key.transform_values { |p| p["data"] }
92
+ counts_before = rows_by_ref_key.transform_values(&:length)
93
+
94
+ rows_by_ref_key = @transform.apply_computed_fields(rows_by_ref_key, payload_by_ref_key, component)
95
+ rows_by_ref_key = @transform.apply_postprocess(rows_by_ref_key, component, @warnings)
96
+
97
+ rows_by_ref_key.each do |key, rows|
98
+ warn_postprocess_emptied(key, counts_before[key]) if rows.empty? && counts_before[key].to_i > 0
99
+ end
100
+
101
+ rows_by_ref_key
102
+ end
103
+
104
+ # --- Component renderers ---
105
+
106
+ def render_chart
107
+ payload_by_ref_key = fetch_and_prepare(component["highchartsData"])
108
+ rows_by_ref_key = transform_rows(payload_by_ref_key)
109
+ highcharts = build_highcharts(rows_by_ref_key, payload_by_ref_key)
110
+
111
+ build_result("chart", "highchartsData" => highcharts, "display" => component["display"])
112
+ end
113
+
114
+ def render_score
115
+ display = component["display"] || {}
116
+ payload_by_ref_key = fetch_and_prepare(display)
117
+ rows_by_ref_key = payload_by_ref_key.transform_values { |p| p["data"] }
118
+ rows_by_ref_key = @transform.apply_computed_fields(rows_by_ref_key, payload_by_ref_key, component)
119
+ resolved_display = resolve_refs(display, payload_by_ref_key, rows_by_ref_key)
120
+
121
+ build_result("score", "display" => resolved_display)
122
+ end
123
+
124
+ def render_table
125
+ return nil unless component["tableData"]
126
+
127
+ table_def = component["tableData"]
128
+ column_defs = Array(table_def["columns"])
129
+
130
+ payload_by_ref_key = fetch_and_prepare(column_defs)
131
+ rows_by_ref_key = transform_rows(payload_by_ref_key)
132
+
133
+ primary_rk = payload_by_ref_key.keys.first
134
+ rows = primary_rk ? (rows_by_ref_key[primary_rk] || []) : []
135
+
136
+ output_rows = rows.map do |row|
137
+ column_defs.each_with_object({}) do |col_def, output_row|
138
+ identifier = col_def["identifier"]
139
+ next if identifier.nil? || identifier.empty?
140
+ output_row[identifier] = resolve_cell_value(col_def["value"], row, payload_by_ref_key)
141
+ end
142
+ end
143
+
144
+ {"id" => component["id"], "type" => "table",
145
+ "tableData" => {"headers" => table_def["headers"], "columns" => output_rows}.compact}
146
+ end
147
+
148
+ def render_filter
149
+ filter_defs = Array(component["filters"])
150
+ return nil if filter_defs.empty?
151
+
152
+ rows_cache = {}
153
+
154
+ resolved_filters = filter_defs.map do |filter_def|
155
+ options_template = filter_def["options"] || {}
156
+ references = @refs.extract_references(options_template)
157
+ parsed = references.map { |ref| @refs.parse(ref) }.first
158
+ rk = parsed ? @refs.ref_key(parsed) : nil
159
+
160
+ payload = if parsed
161
+ rows_cache[rk] ||= @fetcher.coerce_payload(
162
+ data_source.fetch(parsed[:endpoint], request_params, resource: parsed[:resource])
163
+ )
164
+ else
165
+ {"data" => [], "meta" => {}}
166
+ end
167
+
168
+ options = payload["data"].map do |row|
169
+ options_template.transform_values { |tmpl| resolve_filter_value(tmpl, row) }
170
+ end
171
+
172
+ filter_def.merge("options" => options).except("queries")
173
+ end
174
+
175
+ {"id" => component["id"], "type" => "filter", "filters" => resolved_filters}
176
+ end
177
+
178
+ def render_text
179
+ {"id" => component["id"], "type" => "text", "text" => component["text"],
180
+ "subheader" => component["subheader"], "body" => component["body"]}.compact
181
+ end
182
+
183
+ # --- Highcharts builder ---
184
+
185
+ def build_highcharts(rows_by_ref_key, payload_by_ref_key)
186
+ template = CoreExt::Hash.deep_dup(component["highchartsData"])
187
+ series_templates = template.delete("series") || []
188
+ all_categories = nil
189
+
190
+ built_series = series_templates.map do |s_tmpl|
191
+ data_template = s_tmpl.delete("data") || {}
192
+ rk = detect_ref_key(data_template, rows_by_ref_key)
193
+ rows = rk ? (rows_by_ref_key[rk] || []) : []
194
+ meta = rk ? ((payload_by_ref_key[rk] || {})["meta"] || {}) : {}
195
+
196
+ x_field_keys = extract_category_keys(data_template["x"])
197
+ data_points = build_data_points(data_template, rows, meta, rk)
198
+ all_categories = rows.map { |row| @refs.dig(row, x_field_keys) } if x_field_keys
199
+
200
+ resolve_series_fields(s_tmpl, rows, meta: meta).merge("data" => data_points)
201
+ end
202
+
203
+ template["xAxis"] = inject_categories(template["xAxis"], all_categories) if all_categories
204
+ resolve_refs(template, payload_by_ref_key).merge("series" => built_series)
205
+ end
206
+
207
+ def build_data_points(data_template, rows, meta, current_ref_key)
208
+ rows.each_with_index.map do |row, idx|
209
+ data_template.each_with_object({}) do |(key, val), point|
210
+ point[key] = resolve_data_value(key, val, row, idx, meta: meta, current_ref_key: current_ref_key)
211
+ end
212
+ end
213
+ end
214
+
215
+ # --- Reference resolution ---
216
+
217
+ def resolve_refs(obj, payload_by_ref_key, rows_override = nil)
218
+ case obj
219
+ when Hash then obj.transform_values { |v| resolve_refs(v, payload_by_ref_key, rows_override) }
220
+ when Array then obj.map { |v| resolve_refs(v, payload_by_ref_key, rows_override) }
221
+ when String
222
+ return obj unless obj.start_with?("$")
223
+ parsed = @refs.parse(obj.delete_prefix("$"))
224
+ rk = @refs.ref_key(parsed)
225
+ payload = payload_by_ref_key[rk] || {}
226
+ if parsed[:scope] == :meta
227
+ @refs.dig(payload["meta"] || {}, parsed[:keys]) || obj
228
+ else
229
+ rows = rows_override&.dig(rk) || payload["data"] || []
230
+ @refs.dig(rows.first, parsed[:keys]) || obj
231
+ end
232
+ else obj
233
+ end
234
+ end
235
+
236
+ def resolve_data_value(key, val, row, index, meta:, current_ref_key:)
237
+ if val.is_a?(Hash)
238
+ return val.transform_values { |v| resolve_data_value(nil, v, row, index, meta: meta, current_ref_key: current_ref_key) }
239
+ end
240
+ return val unless val.is_a?(String) && val.start_with?("$")
241
+
242
+ parsed = @refs.parse(val.delete_prefix("$"))
243
+ rk = @refs.ref_key(parsed)
244
+
245
+ if rk != current_ref_key
246
+ return index if key == "x"
247
+ return row[parsed[:endpoint]] || row[parsed[:endpoint].to_sym] if parsed[:legacy] && parsed[:keys].empty?
248
+ return nil
249
+ end
250
+
251
+ return index if key == "x"
252
+ return @refs.dig(meta, parsed[:keys]) if parsed[:scope] == :meta
253
+ @refs.dig(row, parsed[:keys])
254
+ end
255
+
256
+ def resolve_series_fields(s_tmpl, rows, meta:)
257
+ s_tmpl.each_with_object({}) do |(key, val), result|
258
+ if val.is_a?(String) && val.start_with?("$")
259
+ parsed = @refs.parse(val.delete_prefix("$"))
260
+ row_keys = (parsed[:legacy] && parsed[:keys].empty?) ? [parsed[:endpoint]] : parsed[:keys]
261
+ resolved = if parsed[:scope] == :meta
262
+ @refs.dig(meta, parsed[:keys])
263
+ else
264
+ @refs.dig(rows.first, row_keys)
265
+ end
266
+ result[key] = resolved.nil? ? val : resolved
267
+ else
268
+ result[key] = val
269
+ end
270
+ end
271
+ end
272
+
273
+ def resolve_cell_value(value_ref, row, payload_by_ref_key)
274
+ if value_ref.is_a?(String) && value_ref.start_with?("$")
275
+ parsed = @refs.parse(value_ref.delete_prefix("$"))
276
+ rk = @refs.ref_key(parsed)
277
+ ep_meta = (payload_by_ref_key[rk] || {})["meta"] || {}
278
+ row_keys = (parsed[:legacy] && parsed[:keys].empty?) ? [parsed[:endpoint]] : parsed[:keys]
279
+ (parsed[:scope] == :meta) ? @refs.dig(ep_meta, parsed[:keys]) : @refs.dig(row, row_keys)
280
+ else
281
+ value_ref
282
+ end
283
+ end
284
+
285
+ def resolve_filter_value(tmpl, row)
286
+ return tmpl unless tmpl.is_a?(String)
287
+
288
+ tmpl.gsub(/\$([a-zA-Z0-9_-]+)\.data\.([a-zA-Z0-9_.]+)/) do
289
+ path = Regexp.last_match(2).split(".")
290
+ @refs.dig(row, path) || Regexp.last_match(0)
291
+ end.gsub(/\$([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_]+)/) do
292
+ field = Regexp.last_match(2)
293
+ next Regexp.last_match(0) if %w[data meta].include?(field)
294
+ (row[field] || row[field.to_sym]) || Regexp.last_match(0)
295
+ end
296
+ end
297
+
298
+ # --- Helpers ---
299
+
300
+ def detect_ref_key(data_template, rows_by_ref_key = {})
301
+ first_candidate = nil
302
+ data_template.each_value do |val|
303
+ next unless val.is_a?(String) && val.start_with?("$")
304
+ rk = @refs.ref_key_for(val.delete_prefix("$"))
305
+ return rk if rows_by_ref_key.key?(rk)
306
+ first_candidate ||= rk
307
+ end
308
+ first_candidate
309
+ end
310
+
311
+ def extract_category_keys(x_ref)
312
+ return nil unless x_ref.is_a?(String) && x_ref.start_with?("$")
313
+ p = @refs.parse(x_ref.delete_prefix("$"))
314
+ return nil if p[:scope] == :meta
315
+ return [p[:endpoint]] if p[:legacy] && p[:keys].empty?
316
+ p[:keys].empty? ? nil : p[:keys]
317
+ end
318
+
319
+ def inject_categories(x_axis, categories)
320
+ case x_axis
321
+ when Array then x_axis.map { |axis| axis.merge("categories" => categories) }
322
+ when Hash then [x_axis.merge("categories" => categories)]
323
+ else [{"categories" => categories}]
324
+ end
325
+ end
326
+
327
+ def build_result(type, fields = {})
328
+ result = {"id" => component["id"], "type" => type}.merge(fields).compact
329
+ result["warnings"] = @warnings if @warnings.any?
330
+ result
331
+ end
332
+
333
+ def warn_no_data(rk)
334
+ msg = "No data returned for '#{rk}'"
335
+ @warnings << msg
336
+ Trevl.logger.warn("[Trevl::Renderer] #{msg} (component: #{component["id"]})")
337
+ end
338
+
339
+ def warn_postprocess_emptied(rk, count_before)
340
+ msg = "Postprocess removed all #{count_before} rows for '#{rk}'"
341
+ @warnings << msg
342
+ Trevl.logger.warn("[Trevl::Renderer] #{msg} (component: #{component["id"]})")
343
+ end
344
+ end
345
+ end