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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/NOTICE +9 -0
- data/README.md +431 -0
- data/Rakefile +10 -0
- data/assets/trendence-logo.svg +17 -0
- data/assets/trevl-logo.svg +20 -0
- data/docs/screenshots/01_bar_chart.png +0 -0
- data/docs/screenshots/02_column_chart.png +0 -0
- data/docs/screenshots/03_line_chart.png +0 -0
- data/docs/screenshots/04_pie_chart.png +0 -0
- data/docs/screenshots/05_computed_fields.png +0 -0
- data/docs/screenshots/06_postprocess_top_n.png +0 -0
- data/docs/screenshots/10_multi_series.png +0 -0
- data/examples/01_bar_chart.yml +16 -0
- data/examples/02_column_chart.yml +19 -0
- data/examples/03_line_chart.yml +21 -0
- data/examples/04_pie_chart.yml +15 -0
- data/examples/05_computed_fields.yml +26 -0
- data/examples/06_postprocess_top_n.yml +21 -0
- data/examples/07_score_kpi.yml +13 -0
- data/examples/08_table.yml +14 -0
- data/examples/09_template_inheritance.yml +25 -0
- data/examples/10_multi_series.yml +23 -0
- data/lib/trevl/auth/bearer_token.rb +23 -0
- data/lib/trevl/configuration.rb +24 -0
- data/lib/trevl/core_ext/hash.rb +37 -0
- data/lib/trevl/data_source/api.rb +86 -0
- data/lib/trevl/data_source/base.rb +19 -0
- data/lib/trevl/data_source/cube.rb +70 -0
- data/lib/trevl/data_source/static.rb +30 -0
- data/lib/trevl/data_source.rb +38 -0
- data/lib/trevl/errors.rb +9 -0
- data/lib/trevl/highcharts_asset.rb +45 -0
- data/lib/trevl/html_renderer.rb +91 -0
- data/lib/trevl/notebook/display.rb +53 -0
- data/lib/trevl/notebook.rb +41 -0
- data/lib/trevl/processor.rb +106 -0
- data/lib/trevl/renderer/data_fetcher.rb +63 -0
- data/lib/trevl/renderer/ref_parser.rb +61 -0
- data/lib/trevl/renderer/transform.rb +104 -0
- data/lib/trevl/renderer.rb +345 -0
- data/lib/trevl/schema/component.json +337 -0
- data/lib/trevl/schema/document.json +41 -0
- data/lib/trevl/template_store.rb +38 -0
- data/lib/trevl/validator.rb +96 -0
- data/lib/trevl/version.rb +5 -0
- data/lib/trevl.rb +99 -0
- data/llms.txt +171 -0
- data/notebooks/demo.ipynb +312 -0
- data/notebooks/exploration.ipynb +164 -0
- data/scripts/generate_screenshots.rb +107 -0
- metadata +137 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Data table with columns. Use when you need tabular output instead of charts.
|
|
2
|
+
components:
|
|
3
|
+
- id: crew_manifest
|
|
4
|
+
type: table
|
|
5
|
+
api: demo
|
|
6
|
+
tableData:
|
|
7
|
+
headers: ["Pirate", "Role", "Grog Ration"]
|
|
8
|
+
columns:
|
|
9
|
+
- identifier: pirate
|
|
10
|
+
value: "$crew.data.name"
|
|
11
|
+
- identifier: role
|
|
12
|
+
value: "$crew.data.role"
|
|
13
|
+
- identifier: grog
|
|
14
|
+
value: "$crew.data.grog_ration"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Template inheritance. Register a template first, then reference it.
|
|
2
|
+
# Component values override template values at the same path (deep merge).
|
|
3
|
+
#
|
|
4
|
+
# Ruby setup:
|
|
5
|
+
# Trevl.template_store.register("pirate_bar", {
|
|
6
|
+
# "highchartsData" => {
|
|
7
|
+
# "chart" => {"type" => "bar"},
|
|
8
|
+
# "colors" => ["#8B4513", "#DAA520"],
|
|
9
|
+
# "plotOptions" => {"bar" => {"borderRadius" => 6}},
|
|
10
|
+
# "credits" => {"enabled" => false}
|
|
11
|
+
# }
|
|
12
|
+
# })
|
|
13
|
+
components:
|
|
14
|
+
- id: plunder_template
|
|
15
|
+
type: chart
|
|
16
|
+
api: demo
|
|
17
|
+
template: pirate_bar
|
|
18
|
+
highchartsData:
|
|
19
|
+
title:
|
|
20
|
+
text: Plunder by Ship (uses pirate_bar template)
|
|
21
|
+
series:
|
|
22
|
+
- name: Plunder
|
|
23
|
+
data:
|
|
24
|
+
x: "$ships.data.ship"
|
|
25
|
+
y: "$ships.data.plunder"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Multiple series from the same data source. Use for comparison charts.
|
|
2
|
+
# Each series maps different fields from the same rows.
|
|
3
|
+
components:
|
|
4
|
+
- id: grog_vs_rum
|
|
5
|
+
type: chart
|
|
6
|
+
api: demo
|
|
7
|
+
highchartsData:
|
|
8
|
+
chart:
|
|
9
|
+
type: column
|
|
10
|
+
title:
|
|
11
|
+
text: Grog vs Rum Sales at the SCUMM Bar
|
|
12
|
+
colors: ["#8B4513", "#DAA520"]
|
|
13
|
+
legend:
|
|
14
|
+
enabled: true
|
|
15
|
+
series:
|
|
16
|
+
- name: Grog (pints)
|
|
17
|
+
data:
|
|
18
|
+
x: "$sales.data.month"
|
|
19
|
+
y: "$sales.data.grog"
|
|
20
|
+
- name: Rum (shots)
|
|
21
|
+
data:
|
|
22
|
+
x: "$sales.data.month"
|
|
23
|
+
y: "$sales.data.rum"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trevl
|
|
4
|
+
module Auth
|
|
5
|
+
# Simple bearer token authentication.
|
|
6
|
+
#
|
|
7
|
+
# auth = Trevl::Auth::BearerToken.new("my-secret-token")
|
|
8
|
+
# Trevl::DataSource::Api.new(base_url: "...", auth: auth)
|
|
9
|
+
#
|
|
10
|
+
# Custom auth objects can be used instead — any object responding to
|
|
11
|
+
# #apply(headers, url:) will work.
|
|
12
|
+
#
|
|
13
|
+
class BearerToken
|
|
14
|
+
def initialize(token)
|
|
15
|
+
@token = token
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def apply(headers, url: nil)
|
|
19
|
+
headers["Authorization"] = "Bearer #{@token}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trevl
|
|
4
|
+
class Configuration
|
|
5
|
+
# Highcharts is commercially licensed and not bundled with this gem, so it
|
|
6
|
+
# is loaded from the CDN unless a local copy is configured.
|
|
7
|
+
HIGHCHARTS_CDN_URL = "https://code.highcharts.com/11.4.0/highcharts.js"
|
|
8
|
+
|
|
9
|
+
attr_accessor :logger, :highcharts_url, :highcharts_path, :highcharts_modules
|
|
10
|
+
attr_writer :template_store
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@logger = Logger.new($stderr, level: Logger::WARN)
|
|
14
|
+
@template_store = nil
|
|
15
|
+
@highcharts_url = HIGHCHARTS_CDN_URL
|
|
16
|
+
@highcharts_path = nil
|
|
17
|
+
@highcharts_modules = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def template_store
|
|
21
|
+
@template_store ||= TemplateStore.new
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trevl
|
|
4
|
+
module CoreExt
|
|
5
|
+
module Hash
|
|
6
|
+
def self.deep_dup(hash)
|
|
7
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
8
|
+
result[key] = case value
|
|
9
|
+
when ::Hash then deep_dup(value)
|
|
10
|
+
when ::Array then value.map { |v| v.is_a?(::Hash) ? deep_dup(v) : v }
|
|
11
|
+
else value
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.deep_stringify_keys(hash)
|
|
17
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
18
|
+
result[key.to_s] = case value
|
|
19
|
+
when ::Hash then deep_stringify_keys(value)
|
|
20
|
+
when ::Array then value.map { |v| v.is_a?(::Hash) ? deep_stringify_keys(v) : v }
|
|
21
|
+
else value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.deep_merge(base, override)
|
|
27
|
+
base.merge(override) do |_key, base_val, override_val|
|
|
28
|
+
if base_val.is_a?(::Hash) && override_val.is_a?(::Hash)
|
|
29
|
+
deep_merge(base_val, override_val)
|
|
30
|
+
else
|
|
31
|
+
override_val
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "httparty"
|
|
4
|
+
|
|
5
|
+
module Trevl
|
|
6
|
+
module DataSource
|
|
7
|
+
# Generic REST API data source.
|
|
8
|
+
#
|
|
9
|
+
# Trevl::DataSource.register("my_api", Trevl::DataSource::Api.new(
|
|
10
|
+
# base_url: "https://api.example.com/v1",
|
|
11
|
+
# auth: Trevl::Auth::BearerToken.new("my-token")
|
|
12
|
+
# ))
|
|
13
|
+
#
|
|
14
|
+
class Api < Base
|
|
15
|
+
include HTTParty
|
|
16
|
+
|
|
17
|
+
EMPTY_RESULT = {"data" => [], "meta" => {}}.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :base_url
|
|
20
|
+
|
|
21
|
+
def initialize(base_url:, auth: nil, default_resource: nil, **)
|
|
22
|
+
super()
|
|
23
|
+
@base_url = base_url.to_s.chomp("/")
|
|
24
|
+
@auth = auth
|
|
25
|
+
@default_resource = default_resource
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def fetch(endpoint, params = {}, resource: nil)
|
|
29
|
+
resource = (resource || @default_resource).to_s
|
|
30
|
+
url = build_url(resource, endpoint)
|
|
31
|
+
headers = build_headers(url)
|
|
32
|
+
body = params.is_a?(Hash) ? params : {}
|
|
33
|
+
|
|
34
|
+
Trevl.logger.info("[Trevl::DataSource::Api] POST #{url}")
|
|
35
|
+
|
|
36
|
+
response = self.class.post(url, body: body.to_json, headers: headers, timeout: 15)
|
|
37
|
+
|
|
38
|
+
unless response.success?
|
|
39
|
+
Trevl.logger.error("[Trevl::DataSource::Api] HTTP #{response.code} from #{url}")
|
|
40
|
+
return EMPTY_RESULT.dup
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
parse_response(response.body)
|
|
44
|
+
rescue JSON::ParserError, Timeout::Error, Errno::ECONNREFUSED => e
|
|
45
|
+
Trevl.logger.error("[Trevl::DataSource::Api] Error: #{e.message}")
|
|
46
|
+
EMPTY_RESULT.dup
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build_url(resource, endpoint)
|
|
52
|
+
parts = [@base_url]
|
|
53
|
+
parts << resource unless resource.empty?
|
|
54
|
+
parts << endpoint unless endpoint.nil? || endpoint.empty?
|
|
55
|
+
parts.join("/")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_headers(url)
|
|
59
|
+
headers = {"Content-Type" => "application/json", "Accept" => "application/json"}
|
|
60
|
+
@auth&.apply(headers, url: url)
|
|
61
|
+
headers
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse_response(body)
|
|
65
|
+
data = JSON.parse(body)
|
|
66
|
+
meta = extract_meta(data)
|
|
67
|
+
rows = extract_rows(data)
|
|
68
|
+
{"data" => rows, "meta" => meta}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def extract_meta(data)
|
|
72
|
+
return {} unless data.is_a?(Hash)
|
|
73
|
+
m = data["metadata"] || data["meta"]
|
|
74
|
+
m.is_a?(Hash) ? m : {}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def extract_rows(data)
|
|
78
|
+
case data
|
|
79
|
+
when Array then data
|
|
80
|
+
when Hash then data["results"] || data["data"] || (data.key?("meta") ? [] : [data])
|
|
81
|
+
else []
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trevl
|
|
4
|
+
module DataSource
|
|
5
|
+
class Base
|
|
6
|
+
def fetch(endpoint, params = {}, resource: nil)
|
|
7
|
+
raise NotImplementedError, "#{self.class}#fetch must be implemented"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Returns the field names available for an endpoint by fetching a sample row.
|
|
11
|
+
# Override in subclasses for more efficient discovery (e.g. schema endpoints).
|
|
12
|
+
def field_names(endpoint, params = {}, resource: nil)
|
|
13
|
+
result = fetch(endpoint, params, resource: resource)
|
|
14
|
+
row = Array(result["data"]).first
|
|
15
|
+
row.is_a?(Hash) ? row.keys : []
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "httparty"
|
|
4
|
+
|
|
5
|
+
module Trevl
|
|
6
|
+
module DataSource
|
|
7
|
+
# Generic CubeJS data source.
|
|
8
|
+
#
|
|
9
|
+
# Trevl::DataSource.register("cube", Trevl::DataSource::Cube.new(
|
|
10
|
+
# url: "https://cube.example.com/cubejs-api/v1",
|
|
11
|
+
# token: ENV["CUBE_TOKEN"]
|
|
12
|
+
# ))
|
|
13
|
+
#
|
|
14
|
+
class Cube < Base
|
|
15
|
+
include HTTParty
|
|
16
|
+
|
|
17
|
+
EMPTY_RESULT = {"data" => [], "meta" => {}}.freeze
|
|
18
|
+
QUERY_KEYS = %w[dimensions measures filters timeDimensions order limit].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(url:, token: nil, **)
|
|
21
|
+
super()
|
|
22
|
+
@url = url.to_s.chomp("/")
|
|
23
|
+
@token = token
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def fetch(endpoint, params = {}, resource: nil)
|
|
27
|
+
query = build_query(params)
|
|
28
|
+
headers = {"Content-Type" => "application/json", "Accept" => "application/json"}
|
|
29
|
+
headers["Authorization"] = @token if @token
|
|
30
|
+
|
|
31
|
+
Trevl.logger.info("[Trevl::DataSource::Cube] Querying #{@url}/load")
|
|
32
|
+
|
|
33
|
+
response = self.class.post(
|
|
34
|
+
"#{@url}/load",
|
|
35
|
+
body: {query: query}.to_json,
|
|
36
|
+
headers: headers,
|
|
37
|
+
timeout: 30
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
unless response.success?
|
|
41
|
+
Trevl.logger.error("[Trevl::DataSource::Cube] HTTP #{response.code}: #{response.body}")
|
|
42
|
+
return EMPTY_RESULT.dup
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
data = JSON.parse(response.body)
|
|
46
|
+
rows = Array(data["data"]).map { |row| flatten_keys(row) }
|
|
47
|
+
{"data" => rows, "meta" => {}}
|
|
48
|
+
rescue JSON::ParserError, Timeout::Error, Errno::ECONNREFUSED => e
|
|
49
|
+
Trevl.logger.error("[Trevl::DataSource::Cube] Error: #{e.message}")
|
|
50
|
+
EMPTY_RESULT.dup
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def build_query(params)
|
|
56
|
+
QUERY_KEYS.each_with_object({}) do |key, query|
|
|
57
|
+
val = params[key] || params[key.to_sym]
|
|
58
|
+
query[key] = val if val
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def flatten_keys(row)
|
|
63
|
+
row.each_with_object({}) do |(key, value), flat|
|
|
64
|
+
flat_key = key.to_s.include?(".") ? key.to_s.split(".").last : key.to_s
|
|
65
|
+
flat[flat_key] = value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trevl
|
|
4
|
+
module DataSource
|
|
5
|
+
class Static < Base
|
|
6
|
+
attr_reader :data
|
|
7
|
+
|
|
8
|
+
def initialize(data: {}, **)
|
|
9
|
+
super()
|
|
10
|
+
@data = data
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fetch(endpoint, params = {}, resource: nil)
|
|
14
|
+
key = resource ? "#{resource}/#{endpoint}" : endpoint
|
|
15
|
+
result = @data[key] || @data[endpoint]
|
|
16
|
+
|
|
17
|
+
case result
|
|
18
|
+
when Array
|
|
19
|
+
{"data" => result, "meta" => {}}
|
|
20
|
+
when Hash
|
|
21
|
+
data = result["data"] || result[:data] || []
|
|
22
|
+
meta = result["meta"] || result[:meta] || {}
|
|
23
|
+
{"data" => Array(data), "meta" => meta.is_a?(Hash) ? meta : {}}
|
|
24
|
+
else
|
|
25
|
+
{"data" => [], "meta" => {}}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trevl
|
|
4
|
+
module DataSource
|
|
5
|
+
class << self
|
|
6
|
+
def register(name, source)
|
|
7
|
+
registry[name.to_s.strip.downcase] = source
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def for(name, **)
|
|
11
|
+
key = name.to_s.strip.downcase
|
|
12
|
+
source = registry[key]
|
|
13
|
+
raise DataSourceError, "No data source registered for '#{name}'. Available: #{registry.keys.join(", ")}." unless source
|
|
14
|
+
|
|
15
|
+
source.is_a?(Class) ? source.new(**) : source
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def all
|
|
19
|
+
registry.map do |name, source|
|
|
20
|
+
obj = source.is_a?(Class) ? source : source.class
|
|
21
|
+
{name: name, type: obj.name&.split("::")&.last || obj.to_s, source: source}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def names
|
|
26
|
+
registry.keys
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def registry
|
|
30
|
+
@registry ||= {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reset!
|
|
34
|
+
@registry = {}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/trevl/errors.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trevl
|
|
4
|
+
# Builds the <script> tags that load Highcharts into generated HTML.
|
|
5
|
+
#
|
|
6
|
+
# Highcharts is commercially licensed and is therefore not shipped with this
|
|
7
|
+
# gem. By default it is loaded from the official CDN. Point the configuration
|
|
8
|
+
# at local copies to keep the output self-contained and usable offline:
|
|
9
|
+
#
|
|
10
|
+
# Trevl.configure do |config|
|
|
11
|
+
# config.highcharts_path = Rails.root.join("vendor/highcharts/highcharts.js")
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Using Highcharts requires a licence from Highsoft. See highcharts.com/license.
|
|
15
|
+
module HighchartsAsset
|
|
16
|
+
class << self
|
|
17
|
+
# Core library plus any configured modules, as ready-to-embed script tags.
|
|
18
|
+
def script_tags
|
|
19
|
+
config = Trevl.configuration
|
|
20
|
+
sources = [[config.highcharts_path, config.highcharts_url]] +
|
|
21
|
+
config.highcharts_modules.map { |m| [local?(m) ? m : nil, m] }
|
|
22
|
+
|
|
23
|
+
sources.map { |path, url| script_tag(path, url) }.join("\n")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# A local file is inlined so the page works without network access;
|
|
29
|
+
# anything else becomes a src reference.
|
|
30
|
+
def script_tag(path, url)
|
|
31
|
+
if path && File.exist?(path.to_s)
|
|
32
|
+
"<script>#{File.read(path.to_s)}</script>"
|
|
33
|
+
elsif path
|
|
34
|
+
raise Trevl::Error, "Highcharts JS not found at #{path}"
|
|
35
|
+
else
|
|
36
|
+
%(<script src="#{url}"></script>)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def local?(source)
|
|
41
|
+
!source.to_s.match?(%r{\Ahttps?://})
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trevl
|
|
4
|
+
# Renders TREVL YAML to an HTML page that draws its charts with Highcharts.
|
|
5
|
+
# Highcharts itself is loaded from the CDN by default; configure
|
|
6
|
+
# `highcharts_path` to inline a local copy and get a self-contained file
|
|
7
|
+
# that works offline.
|
|
8
|
+
#
|
|
9
|
+
# html = Trevl.render_to_html(yaml_string)
|
|
10
|
+
# File.write("chart.html", html)
|
|
11
|
+
#
|
|
12
|
+
# For screenshots, open the HTML file with any headless browser:
|
|
13
|
+
# - Grover gem: Grover.new(html).to_png
|
|
14
|
+
# - Ferrum: browser.goto("file:///tmp/chart.html"); browser.screenshot
|
|
15
|
+
# - Playwright MCP: browser_navigate + browser_take_screenshot
|
|
16
|
+
# - CLI: npx puppeteer screenshot chart.html
|
|
17
|
+
#
|
|
18
|
+
class HtmlRenderer
|
|
19
|
+
def initialize(width: 800, height: 400, background: "#ffffff")
|
|
20
|
+
@width = width
|
|
21
|
+
@height = height
|
|
22
|
+
@background = background
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Renders TREVL YAML to a standalone HTML string.
|
|
26
|
+
# Returns a complete HTML document with all charts embedded.
|
|
27
|
+
def render(yaml_string, params: {}, data: nil, data_sources: {})
|
|
28
|
+
results = Trevl.render(yaml_string, params: params, data: data, data_sources: data_sources)
|
|
29
|
+
charts = results.select { |r| r["type"] == "chart" && r["highchartsData"] }
|
|
30
|
+
scores = results.select { |r| r["type"] == "score" && r["display"] }
|
|
31
|
+
|
|
32
|
+
build_html(charts, scores)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Renders a single pre-rendered component hash to HTML.
|
|
36
|
+
def render_component(result)
|
|
37
|
+
case result["type"]
|
|
38
|
+
when "chart" then build_html([result], [])
|
|
39
|
+
when "score" then build_html([], [result])
|
|
40
|
+
else ""
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def build_html(charts, scores)
|
|
47
|
+
<<~HTML
|
|
48
|
+
<!DOCTYPE html>
|
|
49
|
+
<html>
|
|
50
|
+
<head>
|
|
51
|
+
<meta charset="utf-8">
|
|
52
|
+
<style>
|
|
53
|
+
body { margin: 0; padding: 20px; background: #{@background}; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
|
54
|
+
.trevl-chart { width: #{@width}px; height: #{@height}px; margin: 0 auto 20px; }
|
|
55
|
+
.trevl-score { text-align: center; padding: 20px; margin: 0 auto 20px; }
|
|
56
|
+
.trevl-score-title { font-size: 14px; color: #6b7280; margin-bottom: 8px; }
|
|
57
|
+
.trevl-score-value { font-size: 48px; font-weight: 700; color: #1f2937; }
|
|
58
|
+
</style>
|
|
59
|
+
#{HighchartsAsset.script_tags}
|
|
60
|
+
</head>
|
|
61
|
+
<body>
|
|
62
|
+
#{scores.map { |s| score_html(s) }.join("\n")}
|
|
63
|
+
#{charts.each_with_index.map { |c, i| chart_html(c, i) }.join("\n")}
|
|
64
|
+
</body>
|
|
65
|
+
</html>
|
|
66
|
+
HTML
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def chart_html(result, index)
|
|
70
|
+
container_id = "trevl_chart_#{index}"
|
|
71
|
+
<<~HTML
|
|
72
|
+
<div id="#{container_id}" class="trevl-chart"></div>
|
|
73
|
+
<script>Highcharts.chart('#{container_id}', #{result["highchartsData"].to_json});</script>
|
|
74
|
+
HTML
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def score_html(result)
|
|
78
|
+
display = result["display"] || {}
|
|
79
|
+
value = display["value"]
|
|
80
|
+
unit = display["unit"] || ""
|
|
81
|
+
title = display.dig("header", "title") || ""
|
|
82
|
+
|
|
83
|
+
<<~HTML
|
|
84
|
+
<div class="trevl-score">
|
|
85
|
+
<div class="trevl-score-title">#{title}</div>
|
|
86
|
+
<div class="trevl-score-value">#{value}#{unit}</div>
|
|
87
|
+
</div>
|
|
88
|
+
HTML
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trevl
|
|
4
|
+
class Notebook
|
|
5
|
+
class Display
|
|
6
|
+
def render_chart(highcharts_data, height: 400)
|
|
7
|
+
container_id = "trevl_#{SecureRandom.hex(6)}"
|
|
8
|
+
|
|
9
|
+
html = +""
|
|
10
|
+
html << highcharts_script_tag unless @highcharts_loaded
|
|
11
|
+
html << <<~HTML
|
|
12
|
+
<div id="#{container_id}" style="width:100%;height:#{height}px;"></div>
|
|
13
|
+
<script>
|
|
14
|
+
Highcharts.chart('#{container_id}', #{highcharts_data.to_json});
|
|
15
|
+
</script>
|
|
16
|
+
HTML
|
|
17
|
+
|
|
18
|
+
@highcharts_loaded = true
|
|
19
|
+
display_html(html)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render_score(display_data)
|
|
23
|
+
value = display_data["value"]
|
|
24
|
+
unit = display_data["unit"] || ""
|
|
25
|
+
header = display_data["header"] || {}
|
|
26
|
+
title = header["title"] || ""
|
|
27
|
+
|
|
28
|
+
html = <<~HTML
|
|
29
|
+
<div style="text-align:center;padding:20px;font-family:sans-serif;">
|
|
30
|
+
<div style="font-size:14px;color:#6b7280;margin-bottom:8px;">#{title}</div>
|
|
31
|
+
<div style="font-size:48px;font-weight:700;color:#1f2937;">#{value}#{unit}</div>
|
|
32
|
+
</div>
|
|
33
|
+
HTML
|
|
34
|
+
|
|
35
|
+
display_html(html)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def highcharts_script_tag
|
|
41
|
+
"#{HighchartsAsset.script_tags}\n"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def display_html(html)
|
|
45
|
+
if defined?(IRuby)
|
|
46
|
+
IRuby.display(html, mime: "text/html")
|
|
47
|
+
else
|
|
48
|
+
puts html
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "notebook/display"
|
|
4
|
+
|
|
5
|
+
module Trevl
|
|
6
|
+
class Notebook
|
|
7
|
+
attr_reader :display
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@display = Display.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Render a TREVL YAML string as an interactive Highcharts chart in iRuby.
|
|
14
|
+
# Optionally pass static data to avoid API calls — inline data answers any
|
|
15
|
+
# api name and components without one (Trevl.render data: semantics).
|
|
16
|
+
def chart(yaml_string, params: {}, data: nil, height: 400)
|
|
17
|
+
results = Trevl.render(yaml_string, params: params, data: data)
|
|
18
|
+
results.each do |result|
|
|
19
|
+
next unless result["type"] == "chart" && result["highchartsData"]
|
|
20
|
+
@display.render_chart(result["highchartsData"], height: height)
|
|
21
|
+
end
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Render a score component inline
|
|
26
|
+
def score(yaml_string, params: {}, data: nil)
|
|
27
|
+
results = Trevl.render(yaml_string, params: params, data: data)
|
|
28
|
+
results.each do |result|
|
|
29
|
+
next unless result["type"] == "score"
|
|
30
|
+
@display.render_score(result["display"] || result)
|
|
31
|
+
end
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Fetch raw data from a registered data source
|
|
36
|
+
def fetch(source_name, endpoint, params = {}, resource: nil)
|
|
37
|
+
source = DataSource.for(source_name)
|
|
38
|
+
source.fetch(endpoint, params, resource: resource)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|