janela 0.14.0 → 0.16.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 +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +26 -8
- data/UPGRADING.md +59 -0
- data/app/assets/javascripts/janela/chart_controller.js +148 -42
- data/app/assets/stylesheets/janela.css +7 -0
- data/app/controllers/janela/panes_controller.rb +2 -2
- data/app/controllers/janela/queries_controller.rb +12 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +2 -0
- data/app/helpers/janela/frames_helper.rb +37 -9
- data/app/models/janela/pane.rb +33 -2
- data/app/models/janela/query.rb +252 -27
- data/app/models/janela/snapshot.rb +2 -2
- data/app/views/janela/frames/show.html.erb +2 -2
- data/app/views/janela/panes/_form.html.erb +53 -0
- data/app/views/janela/queries/_query.html.erb +18 -5
- data/config/locales/en.yml +26 -1
- data/db/migrate/20261013000001_add_sort_to_janela_panes.rb +7 -0
- data/db/migrate/20261029000001_add_series_to_janela_panes.rb +9 -0
- data/db/migrate/20261029000003_add_ignore_unknown_filters_to_janela_panes.rb +8 -0
- data/docs/agents.md +33 -2
- data/docs/decisions/018-a-table-is-the-universal-renderer.md +1 -0
- data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +1 -0
- data/docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md +1 -1
- data/docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md +204 -0
- data/docs/decisions/056-the-demo-counts-its-own-visits-and-an-agent-arranges-the-dashboard.md +51 -0
- data/docs/decisions/057-a-host-is-told-about-the-agent-tools-and-a-generator-sets-them-up.md +197 -0
- data/docs/decisions/058-the-deployed-demo-is-reached-over-ssh-and-its-visits-frame-survives-a-reset.md +94 -0
- data/docs/decisions/059-a-host-can-tell-janelas-own-pages-that-its-layout-draws-charts.md +91 -0
- data/docs/decisions/060-the-demo-tells-search-engines-what-it-is-and-shows-its-own-adoption.md +79 -0
- data/docs/decisions/061-the-demo-speaks-brazilian-portuguese-under-a-prefix-and-the-gem-stays-english.md +118 -0
- data/docs/decisions/062-a-time-pane-can-accumulate-and-carry-more-series-on-two-axes.md +282 -0
- data/docs/decisions/063-a-pane-can-ignore-a-filter-its-model-cannot-apply.md +147 -0
- data/docs/decisions/INDEX.md +25 -16
- data/docs/roadmap.md +10 -4
- data/lib/generators/janela/mcp/mcp_generator.rb +51 -0
- data/lib/generators/janela/mcp/templates/janela-mcp.tt +50 -0
- data/lib/janela/definition.rb +73 -4
- data/lib/janela/dimension.rb +9 -1
- data/lib/janela/doctor.rb +10 -1
- data/lib/janela/measure.rb +8 -0
- data/lib/janela/tools.rb +37 -4
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +12 -0
- metadata +22 -8
|
@@ -34,26 +34,36 @@ module Janela
|
|
|
34
34
|
# server draws the pane from it again on every cross-filter. It is not part
|
|
35
35
|
# of the frame's id: how tall a pane is does not say which query it is
|
|
36
36
|
# (ADR 047, ADR 029).
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
#
|
|
38
|
+
# cumulative: is a running total across a time pane's buckets, and series:
|
|
39
|
+
# a list of further measures drawn beside it (ADR 062). Both travel in the
|
|
40
|
+
# URL for the same reason; a set cumulative is also part of the frame's
|
|
41
|
+
# id, since a measure to date and per period are two different answers.
|
|
42
|
+
# ignore_unknown_filters: drops the reader's filters this pane's model cannot
|
|
43
|
+
# apply rather than refusing them, for a pane beside panes over another
|
|
44
|
+
# model (ADR 063).
|
|
45
|
+
def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, cumulative: nil, series: nil, ignore_unknown_filters: nil, id: nil)
|
|
46
|
+
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, sort: sort, height: height, prominence: prominence,
|
|
47
|
+
value_labels: (1 if value_labels), companions: companions.presence, cumulative: janela_cumulative_param(cumulative), series: janela_series_params(series),
|
|
48
|
+
ignore_unknown_filters: (1 if ignore_unknown_filters),
|
|
40
49
|
where: @janela_fixed_filters.presence }.compact
|
|
41
50
|
base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
|
|
42
51
|
|
|
43
|
-
turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
|
|
52
|
+
turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, sort: sort, cumulative: cumulative, series: series),
|
|
44
53
|
src: janela_with_page_filters(base),
|
|
45
54
|
loading: :lazy,
|
|
46
55
|
data: { janela__frame_target: "pane", janela_src: base }
|
|
47
56
|
end
|
|
48
57
|
|
|
49
58
|
# A pane as it was when the snapshot was taken: same shape as janela_pane,
|
|
50
|
-
# not part of the live frame's filter state (ADR 009).
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
# not part of the live frame's filter state (ADR 009). A snapshot holds
|
|
60
|
+
# no series, as it holds no companions, so it takes none (ADR 062).
|
|
61
|
+
def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, cumulative: nil)
|
|
62
|
+
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, sort: sort, height: height,
|
|
63
|
+
prominence: prominence, value_labels: (1 if value_labels), companions: companions.presence, cumulative: janela_cumulative_param(cumulative) }.compact
|
|
54
64
|
src = janela_routes.snapshot_pane_path(snapshot, model.model_name.route_key, measure, by, **query)
|
|
55
65
|
|
|
56
|
-
turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, snapshot: snapshot),
|
|
66
|
+
turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, sort: sort, cumulative: cumulative, snapshot: snapshot),
|
|
57
67
|
src: src, loading: :lazy
|
|
58
68
|
end
|
|
59
69
|
|
|
@@ -74,6 +84,24 @@ module Janela
|
|
|
74
84
|
end
|
|
75
85
|
|
|
76
86
|
private
|
|
87
|
+
# Read the way the server reads the flag, so "0" and "false" are no in
|
|
88
|
+
# the URL and in the frame's id alike.
|
|
89
|
+
def janela_cumulative_param(cumulative)
|
|
90
|
+
1 if Query.flag!("cumulative", cumulative)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Each series led by its measure, because Rack opens a new hash in
|
|
94
|
+
# series[][...] where a key repeats and otherwise adds to the last one,
|
|
95
|
+
# and to_query keeps the order it is given inside a [] (ADR 062).
|
|
96
|
+
def janela_series_params(series)
|
|
97
|
+
return if series.blank?
|
|
98
|
+
|
|
99
|
+
series.map do |each|
|
|
100
|
+
given = each.to_h.symbolize_keys
|
|
101
|
+
{ measure: given[:measure] }.merge(given.except(:measure)).compact
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
77
105
|
# Wired once per frame, so a host asks a pane to go to a different query
|
|
78
106
|
# by dispatching an event from anywhere inside rather than by reaching
|
|
79
107
|
# for the controller itself (ADR 030).
|
data/app/models/janela/pane.rb
CHANGED
|
@@ -14,6 +14,10 @@ module Janela
|
|
|
14
14
|
# How prominent a single value is drawn (ADR 050).
|
|
15
15
|
PROMINENCES = Query::PROMINENCES
|
|
16
16
|
LIMITS = (1..1000).freeze
|
|
17
|
+
|
|
18
|
+
# How a category pane is ordered (ADR 055). Nil is the default, largest
|
|
19
|
+
# first by the measure.
|
|
20
|
+
SORTS = Definition::SORTS
|
|
17
21
|
# A form cannot offer a thousand options, and these are the row counts a
|
|
18
22
|
# dashboard actually asks for. Any limit inside LIMITS is still valid.
|
|
19
23
|
OFFERED_LIMITS = [ 5, 10, 20, 50, 100 ].freeze
|
|
@@ -38,10 +42,15 @@ module Janela
|
|
|
38
42
|
validates :kind, inclusion: { in: KINDS }
|
|
39
43
|
validates :span, inclusion: { in: SPANS }
|
|
40
44
|
validates :limit, inclusion: { in: LIMITS }, allow_nil: true
|
|
45
|
+
validates :sort, inclusion: { in: SORTS }, allow_nil: true
|
|
41
46
|
validates :height, inclusion: { in: HEIGHTS }, allow_nil: true
|
|
42
47
|
validates :prominence, inclusion: { in: PROMINENCES }, allow_nil: true
|
|
48
|
+
# A form sends an empty string for "Largest first", which is unset.
|
|
49
|
+
normalizes :sort, with: ->(value) { value.presence }
|
|
43
50
|
before_validation :normalise_companions
|
|
44
51
|
validate :companions_are_declared
|
|
52
|
+
before_validation :normalise_series
|
|
53
|
+
validate :series_are_declared
|
|
45
54
|
validates :measure, presence: true, if: :query?
|
|
46
55
|
validate :declared_by_a_janela_block, if: :query?
|
|
47
56
|
validate :holds_no_query, unless: :query?
|
|
@@ -84,7 +93,8 @@ module Janela
|
|
|
84
93
|
# instead (ADR 018).
|
|
85
94
|
def query(filters: {}, fixed: {}, renderer: self.renderer)
|
|
86
95
|
Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
|
|
87
|
-
renderer: renderer, granularity: granularity, limit: limit, height: height, prominence: prominence, value_labels: value_labels, companions: companions,
|
|
96
|
+
renderer: renderer, granularity: granularity, limit: limit, sort: sort, height: height, prominence: prominence, value_labels: value_labels, companions: companions,
|
|
97
|
+
cumulative: cumulative, series: series, ignore_unknown_filters: ignore_unknown_filters, filters: filters, fixed: fixed,
|
|
88
98
|
default: frame.default_for(definition.model), title: title)
|
|
89
99
|
end
|
|
90
100
|
|
|
@@ -138,6 +148,27 @@ module Janela
|
|
|
138
148
|
nil # an unknown model is reported by its own validation
|
|
139
149
|
end
|
|
140
150
|
|
|
151
|
+
# The form sends a blank slot beyond the series it holds, and a series
|
|
152
|
+
# with no measure is no series. An empty list is nothing rather than [].
|
|
153
|
+
def normalise_series
|
|
154
|
+
self.series = Array(series).map { |each| each.to_h.stringify_keys.transform_values(&:to_s).reject { |_, value| value.blank? } }
|
|
155
|
+
.reject { |each| each["measure"].blank? }.presence
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The same rules a URL is held to, from the same place, so a row cannot
|
|
159
|
+
# name what a request could not (ADR 062). A running total on a pane
|
|
160
|
+
# that is not over time is refused the same way, by the query.
|
|
161
|
+
def series_are_declared
|
|
162
|
+
return if (series.blank? && !cumulative) || !query? || measure.blank? || Query::RENDERERS.exclude?(renderer.to_s)
|
|
163
|
+
|
|
164
|
+
Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
|
|
165
|
+
renderer: renderer, cumulative: cumulative, series: series)
|
|
166
|
+
rescue Janela::BadRequest => error
|
|
167
|
+
errors.add(error.message.start_with?("cumulative") ? :cumulative : :series, error.message)
|
|
168
|
+
rescue Janela::Error
|
|
169
|
+
nil # an unknown model or dimension is reported by its own validation
|
|
170
|
+
end
|
|
171
|
+
|
|
141
172
|
def panes_above
|
|
142
173
|
frame.panes.where(position: ...position).order(:position)
|
|
143
174
|
end
|
|
@@ -168,7 +199,7 @@ module Janela
|
|
|
168
199
|
# A row is one thing or the other, so the database never holds half a
|
|
169
200
|
# query that nothing will run.
|
|
170
201
|
def holds_no_query
|
|
171
|
-
%i[model measure dimension granularity].each do |column|
|
|
202
|
+
%i[model measure dimension granularity cumulative series].each do |column|
|
|
172
203
|
errors.add(column, "belongs to a query pane, not a #{kind} one") if self[column].present?
|
|
173
204
|
end
|
|
174
205
|
end
|
data/app/models/janela/query.rb
CHANGED
|
@@ -33,17 +33,64 @@ module Janela
|
|
|
33
33
|
# dimension's shared fact, by label.
|
|
34
34
|
Companion = Struct.new(:name, :header, :fact, :cells, keyword_init: true)
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
# A time pane may carry up to three more series, each a measure over a
|
|
37
|
+
# time dimension of the same model, drawn as a line or bars on the left
|
|
38
|
+
# axis or a right one (ADR 062). Bounded as companions are, since each is
|
|
39
|
+
# a query. The keys are the words janela_pane takes, so a series reads the
|
|
40
|
+
# same in a row, in the helper and in the URL.
|
|
41
|
+
MAX_SERIES = 3
|
|
42
|
+
AXES = %w[left right].freeze
|
|
43
|
+
SERIES_KEYS = %w[measure by cumulative as axis title].freeze
|
|
44
|
+
# A series is joined to the pane's buckets one step at a time, so a column
|
|
45
|
+
# with a stray year 1900 would otherwise build a hundred thousand of them.
|
|
46
|
+
MAX_BUCKETS = 10_000
|
|
47
|
+
Series = Struct.new(:measure, :dimension, :cumulative, :renderer, :axis, :title, :values, keyword_init: true)
|
|
48
|
+
|
|
49
|
+
attr_reader :definition, :measure, :dimension, :renderer, :limit, :sort, :height, :prominence, :value_labels, :companions, :cumulative, :series, :ignore_unknown_filters, :filters, :fixed, :default, :snapshot
|
|
37
50
|
|
|
38
51
|
# The helper renders the turbo frame and the controller renders its
|
|
39
52
|
# replacement, so both derive the id the same way from the same parameters.
|
|
40
|
-
|
|
53
|
+
# A measure to date and the same measure per period are two answers that
|
|
54
|
+
# may share a page, so a set cumulative is part of the id (ADR 062, as a
|
|
55
|
+
# sort is in ADR 055). Two panes that differ only in their series are two
|
|
56
|
+
# frames too, and a series is a hash, so it joins the id as a short digest
|
|
57
|
+
# of what was given.
|
|
58
|
+
def self.turbo_frame_id(model:, measure:, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, cumulative: nil, series: nil, snapshot: nil)
|
|
41
59
|
parts = [ "janela", ("snapshot_#{snapshot.id}" if snapshot), model.model_name.route_key, measure, by,
|
|
42
|
-
(granularity if by), (as unless by.nil?), ("top#{limit}" if by && limit)
|
|
60
|
+
(granularity if by), (as unless by.nil?), ("top#{limit}" if by && limit),
|
|
61
|
+
(sort if by && sort.present? && sort.to_s != "desc"), ("cumulative" if by && flag!("cumulative", cumulative)),
|
|
62
|
+
(series_digest(series) if by) ]
|
|
43
63
|
parts.compact.join("_")
|
|
44
64
|
end
|
|
45
65
|
|
|
46
|
-
|
|
66
|
+
# The same words in any spelling a host, a URL or a row gives them: symbol
|
|
67
|
+
# or string keys, true or "1", a blank left out. Nil when there are none,
|
|
68
|
+
# so a pane without series keeps the id it always had.
|
|
69
|
+
def self.series_digest(series)
|
|
70
|
+
given = Array(series).filter_map do |each|
|
|
71
|
+
next unless each.respond_to?(:to_h)
|
|
72
|
+
|
|
73
|
+
words = each.to_h.to_h { |key, value| [ key.to_s, value.to_s ] }.reject { |_, value| value.blank? }
|
|
74
|
+
words["cumulative"] = "1" if words.key?("cumulative") && flag!("cumulative", words["cumulative"])
|
|
75
|
+
words.delete("cumulative") unless words["cumulative"] == "1"
|
|
76
|
+
words.sort.to_h
|
|
77
|
+
end
|
|
78
|
+
return if given.empty?
|
|
79
|
+
|
|
80
|
+
"s#{Digest::SHA1.hexdigest(given.to_json)[0, 8]}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A yes or a no from a URL or a form, with unset the same as no. Anything
|
|
84
|
+
# else is refused rather than read as one of them.
|
|
85
|
+
def self.flag!(name, value)
|
|
86
|
+
case value.to_s
|
|
87
|
+
when "", "0", "false" then false
|
|
88
|
+
when "1", "true" then true
|
|
89
|
+
else raise BadRequest, "#{name} must be 1 or 0, got #{value.inspect}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, cumulative: nil, series: nil, ignore_unknown_filters: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
|
|
47
94
|
@definition = definition
|
|
48
95
|
@title = title
|
|
49
96
|
@measure = measure
|
|
@@ -57,10 +104,17 @@ module Janela
|
|
|
57
104
|
raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
|
|
58
105
|
@granularity = Dimension.granularity!(granularity) if granularity.present?
|
|
59
106
|
@limit = definition.limit!(limit) if limit.present?
|
|
107
|
+
# Unset and desc are the same pane, and stay the same id and the same
|
|
108
|
+
# snapshot key (ADR 055).
|
|
109
|
+
chosen = definition.sort!(sort)
|
|
110
|
+
@sort = chosen unless chosen == "desc"
|
|
60
111
|
@height = height!(height) if height.present?
|
|
61
112
|
@prominence = prominence!(prominence) if prominence.present?
|
|
62
|
-
@value_labels =
|
|
113
|
+
@value_labels = flag!("value_labels", value_labels)
|
|
114
|
+
@ignore_unknown_filters = flag!("ignore_unknown_filters", ignore_unknown_filters)
|
|
63
115
|
@companions = companions!(companions)
|
|
116
|
+
@cumulative = cumulative!(cumulative)
|
|
117
|
+
@series = series!(series)
|
|
64
118
|
end
|
|
65
119
|
|
|
66
120
|
def model
|
|
@@ -121,11 +175,11 @@ module Janela
|
|
|
121
175
|
single_value? && !prominence.nil?
|
|
122
176
|
end
|
|
123
177
|
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
178
|
+
# A bar has a bar to put a value on, and a line has an end to write its
|
|
179
|
+
# latest value at (ADR 054, ADR 062). A ring, a table and a single value
|
|
180
|
+
# ignore the flag, so a pane switched between renderers keeps it.
|
|
127
181
|
def labelled?
|
|
128
|
-
value_labels && chart?
|
|
182
|
+
value_labels && chart?
|
|
129
183
|
end
|
|
130
184
|
|
|
131
185
|
def boxed?
|
|
@@ -206,7 +260,7 @@ module Janela
|
|
|
206
260
|
|
|
207
261
|
def turbo_frame_id
|
|
208
262
|
self.class.turbo_frame_id(model: model, measure: measure, by: dimension, as: renderer,
|
|
209
|
-
granularity: @granularity, limit: limit, snapshot: snapshot)
|
|
263
|
+
granularity: @granularity, limit: limit, sort: sort, cumulative: cumulative, series: @series_given, snapshot: snapshot)
|
|
210
264
|
end
|
|
211
265
|
|
|
212
266
|
# A pane row may carry its own title, which is the first analyst authored
|
|
@@ -217,20 +271,81 @@ module Janela
|
|
|
217
271
|
base = if single_value?
|
|
218
272
|
measure.to_s.humanize
|
|
219
273
|
else
|
|
220
|
-
by = "#{
|
|
274
|
+
by = "#{series_label} by #{dimension.to_s.humanize}"
|
|
221
275
|
time? ? "#{by} per #{granularity}" : by
|
|
222
276
|
end
|
|
223
277
|
frozen? ? "#{base} as of #{snapshot.taken_at.strftime('%-d %b %Y')}" : base
|
|
224
278
|
end
|
|
225
279
|
|
|
280
|
+
# What a series is called in a legend and at the head of its column: its
|
|
281
|
+
# title if it has one, else the measure's name, then the dimension's when
|
|
282
|
+
# it is not the pane's own, then "to date" when it is a running total. The
|
|
283
|
+
# pane's own series is named by the same rule, so the same measure to date
|
|
284
|
+
# and per period never read the same (ADR 062).
|
|
285
|
+
def series_label(each = nil)
|
|
286
|
+
return each.title if each&.title.present?
|
|
287
|
+
|
|
288
|
+
label = (each ? each.measure : measure).to_s.humanize
|
|
289
|
+
label = "#{label} by #{each.dimension.to_s.humanize}" if each && each.dimension != dimension
|
|
290
|
+
(each ? each.cumulative : cumulative) ? I18n.t("janela.series.to_date", label: label) : label
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Series are drawn by a bar, a line and a table. A ring ignores them, as it
|
|
294
|
+
# ignores a height, and a stored pane holds none (ADR 062, ADR 051).
|
|
295
|
+
def series?
|
|
296
|
+
series.any? && !frozen? && !ring?
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The series after the pane's own, once a result has been read, each with
|
|
300
|
+
# its values by label over the same buckets.
|
|
301
|
+
def drawn_series
|
|
302
|
+
series? ? series : []
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# A series' numbers read the way its own measure formats them (ADR 020).
|
|
306
|
+
def series_format(each, value)
|
|
307
|
+
definition.measure!(each.measure).format(value)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def series_tick_format(each)
|
|
311
|
+
declared = definition.measure!(each.measure)
|
|
312
|
+
{ prefix: declared.prefix.to_s, suffix: declared.suffix.to_s, ratio: declared.ratio? }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# What the chart controller needs for each further series: the values and
|
|
316
|
+
# the formatted strings in the primary's label order, and how to draw it.
|
|
317
|
+
def chart_series(result)
|
|
318
|
+
drawn_series.map do |each|
|
|
319
|
+
{ label: series_label(each), type: each.renderer, axis: each.axis,
|
|
320
|
+
values: result.keys.map { |label| each.values[label]&.to_f },
|
|
321
|
+
tick_format: series_tick_format(each),
|
|
322
|
+
formatted: result.keys.map { |label| series_format(each, each.values[label]) } }
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# A right axis exists when a series asks for it, and then both are titled
|
|
327
|
+
# with the names of the series on them: an untitled second scale is how
|
|
328
|
+
# two scales mislead (ADR 062). Nil without one, so a chart with every
|
|
329
|
+
# series on the left is drawn as it always was.
|
|
330
|
+
def axis_titles
|
|
331
|
+
right = drawn_series.select { |each| each.axis == "right" }
|
|
332
|
+
return if right.empty?
|
|
333
|
+
|
|
334
|
+
left = [ series_label ] + drawn_series.reject { |each| each.axis == "right" }.map { |each| series_label(each) }
|
|
335
|
+
{ left: left.join(", "), right: right.map { |each| series_label(each) }.join(", ") }
|
|
336
|
+
end
|
|
337
|
+
|
|
226
338
|
# What identifies this pane's data inside a snapshot.
|
|
227
339
|
def lookup_key
|
|
228
340
|
{ "model" => model.model_name.route_key, "measure" => measure.to_s, "dimension" => dimension&.to_s,
|
|
229
|
-
"granularity" => granularity&.to_s, "limit" => limit }
|
|
341
|
+
"granularity" => granularity&.to_s, "limit" => limit }.tap { |key| key["sort"] = sort if sort }
|
|
230
342
|
end
|
|
231
343
|
|
|
232
344
|
def result(on: nil)
|
|
233
|
-
|
|
345
|
+
# A snapshot keeps per-bucket numbers, and a running total of them is
|
|
346
|
+
# summed on read as a live one is, so what is stored does not change
|
|
347
|
+
# (ADR 062, ADR 009).
|
|
348
|
+
return accumulated(snapshot.stored_result(self), cumulative) if frozen?
|
|
234
349
|
|
|
235
350
|
# Default, then fixed, then the reader's: a frame's own permanent
|
|
236
351
|
# filter narrows first, a host's per-record filter narrows again, and
|
|
@@ -238,7 +353,7 @@ module Janela
|
|
|
238
353
|
# selection, which this pane shows the alternatives to (ADR 040, 043).
|
|
239
354
|
on = definition.narrow(on || model.all, default) if default.present?
|
|
240
355
|
on = definition.narrow(on || model.all, fixed) if fixed.present?
|
|
241
|
-
primary = time? ? time_result(on) : definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
|
|
356
|
+
primary = time? ? time_result(on) : definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit, sort: sort)
|
|
242
357
|
@companion_columns = companions? ? fetch_companions(primary, on) : []
|
|
243
358
|
primary
|
|
244
359
|
end
|
|
@@ -261,7 +376,7 @@ module Janela
|
|
|
261
376
|
end
|
|
262
377
|
|
|
263
378
|
def measure_header
|
|
264
|
-
|
|
379
|
+
series_label
|
|
265
380
|
end
|
|
266
381
|
|
|
267
382
|
# The two filters a click on this label writes, for a time pane: the start
|
|
@@ -339,10 +454,62 @@ module Janela
|
|
|
339
454
|
|
|
340
455
|
# A time pane's series is read as buckets, not labels, and the buckets
|
|
341
456
|
# are kept: the labels alone cannot say what range a click covers.
|
|
457
|
+
# Further series are read the same way, each by its own dimension, and
|
|
458
|
+
# every bucket any of them has becomes one shared x axis, zero where a
|
|
459
|
+
# series has no row (ADR 062).
|
|
342
460
|
def time_result(on)
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
461
|
+
read = definition.series(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity)
|
|
462
|
+
if series?
|
|
463
|
+
extra = series.map { |each| [ each, definition.series(each.measure, by: each.dimension, where: applicable_filters, on: on, granularity: granularity) ] }
|
|
464
|
+
buckets = spanning((read.keys + extra.flat_map { |_, result| result.keys }).map(&:in_time_zone))
|
|
465
|
+
read = aligned(read, buckets, measure)
|
|
466
|
+
extra.each { |each, result| each.values = labelled(accumulated(aligned(result, buckets, each.measure), each.cumulative)) }
|
|
467
|
+
end
|
|
468
|
+
@buckets = read.keys.to_h { |bucket| [ dimension_definition.label(bucket, granularity), bucket ] }
|
|
469
|
+
labelled(accumulated(read, cumulative))
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def labelled(result)
|
|
473
|
+
result.transform_keys { |bucket| dimension_definition.label(bucket, granularity) }
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Every bucket from the first any series has to the last, one step
|
|
477
|
+
# apart. Groupdate fills the gaps inside each series' own range, and two
|
|
478
|
+
# series whose ranges do not meet would otherwise leave a hole between
|
|
479
|
+
# them on the shared axis.
|
|
480
|
+
def spanning(instants)
|
|
481
|
+
return [] if instants.empty?
|
|
482
|
+
|
|
483
|
+
step = Dimension::STEPS.fetch(granularity)
|
|
484
|
+
buckets = []
|
|
485
|
+
bucket = instants.min
|
|
486
|
+
last = instants.max
|
|
487
|
+
while bucket <= last
|
|
488
|
+
raise BadRequest, "these series span more than #{MAX_BUCKETS} #{granularity} buckets, so they cannot share one axis" if buckets.size >= MAX_BUCKETS
|
|
489
|
+
|
|
490
|
+
buckets << bucket
|
|
491
|
+
bucket += step
|
|
492
|
+
end
|
|
493
|
+
buckets
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Groupdate keys a date column's buckets as Date and a timestamp's as
|
|
497
|
+
# Time, so two series are joined through the instant each bucket starts.
|
|
498
|
+
# A bucket with no row is zero for a count or a sum and a gap for an
|
|
499
|
+
# average, a minimum, a maximum or a ratio, which have no value there.
|
|
500
|
+
def aligned(result, buckets, name)
|
|
501
|
+
by_instant = result.transform_keys(&:in_time_zone)
|
|
502
|
+
empty = definition.measure!(name).additive? ? 0 : nil
|
|
503
|
+
buckets.to_h { |bucket| [ bucket, by_instant.fetch(bucket, empty) ] }
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# A running total, summed after the read so the query, the buckets and
|
|
507
|
+
# the click are what they always were (ADR 062).
|
|
508
|
+
def accumulated(result, running)
|
|
509
|
+
return result unless running
|
|
510
|
+
|
|
511
|
+
total = 0
|
|
512
|
+
result.transform_values { |value| total += (value || 0) }
|
|
346
513
|
end
|
|
347
514
|
|
|
348
515
|
# The buckets that lie wholly inside the range the filters name. Janela
|
|
@@ -408,12 +575,65 @@ module Janela
|
|
|
408
575
|
|
|
409
576
|
# A yes or a no from a URL or a form, with unset the same as no. Anything
|
|
410
577
|
# else is refused rather than read as one of them.
|
|
411
|
-
def
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
578
|
+
def flag!(name, value)
|
|
579
|
+
self.class.flag!(name, value)
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# A running total is across buckets, so only a time pane has one. On a
|
|
583
|
+
# category pane or a single value it is refused with its reason rather
|
|
584
|
+
# than ignored: a row whose dimension is changed to a category is told on
|
|
585
|
+
# the form, not left drawing something else (ADR 062).
|
|
586
|
+
def cumulative!(value)
|
|
587
|
+
running = flag!("cumulative", value)
|
|
588
|
+
raise BadRequest, "cumulative applies to a time dimension, and this pane is not over one" if running && !time?
|
|
589
|
+
raise BadRequest, unsummable(measure) if running && !definition.measure!(measure).additive?
|
|
590
|
+
|
|
591
|
+
running
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def unsummable(name)
|
|
595
|
+
"cumulative applies to a measure that adds up, a count or a sum, and #{name.inspect} is " \
|
|
596
|
+
"#{definition.measure!(name.to_sym).aggregate == :ratio ? 'a ratio' : "an #{definition.measure!(name.to_sym).aggregate}"}"
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def series!(value)
|
|
600
|
+
@series_given = value
|
|
601
|
+
return [] if value.blank?
|
|
602
|
+
raise BadRequest, "series must be a list of hashes, got #{value.class}" unless value.is_a?(Array) && value.all?(Hash)
|
|
603
|
+
raise BadRequest, "series apply to a time dimension, and this pane is not over one" unless time?
|
|
604
|
+
raise BadRequest, "a pane may carry at most #{MAX_SERIES} series, got #{value.size}" if value.size > MAX_SERIES
|
|
605
|
+
|
|
606
|
+
built = value.map { |each| series_from(each.stringify_keys) }
|
|
607
|
+
named = built.map { |each| [ each.measure, each.dimension, each.cumulative ] }
|
|
608
|
+
raise BadRequest, "a series is given twice: the same measure, dimension and cumulative" unless named == named.uniq
|
|
609
|
+
|
|
610
|
+
built
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# Only what the model declared, by the name it declared it under, as a
|
|
614
|
+
# companion is (ADR 025, ADR 051). What is left unsaid is the pane's own.
|
|
615
|
+
def series_from(given)
|
|
616
|
+
unknown = given.keys - SERIES_KEYS
|
|
617
|
+
raise BadRequest, "a series takes #{SERIES_KEYS.join(', ')}, not #{unknown.join(', ')}" if unknown.any?
|
|
618
|
+
|
|
619
|
+
name = given["measure"].to_s
|
|
620
|
+
raise BadRequest, "a series needs a measure" if name.blank?
|
|
621
|
+
raise BadRequest, "#{name.inspect} is not a measure of #{model}. Declared: #{definition.measures.keys.join(', ')}" unless definition.measures.key?(name.to_sym)
|
|
622
|
+
|
|
623
|
+
by = given["by"].present? ? given["by"].to_s.to_sym : dimension
|
|
624
|
+
declared = definition.dimensions.fetch(by) { raise BadRequest, "#{by.inspect} is not a dimension of #{model}" }
|
|
625
|
+
raise BadRequest, "#{by.inspect} is not a time dimension, and a series shares the pane's buckets" unless declared.time?
|
|
626
|
+
|
|
627
|
+
drawn = given["as"].present? ? given["as"].to_s : (CANVAS.include?(renderer) ? renderer : "line")
|
|
628
|
+
raise BadRequest, "a series is drawn as #{CANVAS.join(' or ')}, not #{drawn.inspect}" unless CANVAS.include?(drawn)
|
|
629
|
+
axis = given["axis"].present? ? given["axis"].to_s : "left"
|
|
630
|
+
raise BadRequest, "a series' axis is #{AXES.join(' or ')}, not #{axis.inspect}" unless AXES.include?(axis)
|
|
631
|
+
|
|
632
|
+
running = flag!("cumulative", given["cumulative"])
|
|
633
|
+
raise BadRequest, unsummable(name) if running && !definition.measures.fetch(name.to_sym).additive?
|
|
634
|
+
|
|
635
|
+
Series.new(measure: name.to_sym, dimension: by, cumulative: running,
|
|
636
|
+
renderer: drawn, axis: axis, title: given["title"].presence&.to_s)
|
|
417
637
|
end
|
|
418
638
|
|
|
419
639
|
def prominence!(value)
|
|
@@ -430,10 +650,15 @@ module Janela
|
|
|
430
650
|
step
|
|
431
651
|
end
|
|
432
652
|
|
|
653
|
+
# The reader's filters this pane applies: every one but its own
|
|
654
|
+
# dimension's, which is the cross-filtering rule, and when the pane asks
|
|
655
|
+
# for it, only those its model can apply (ADR 063). A host's fixed and a
|
|
656
|
+
# frame's default filter are not read here and are refused when wrong.
|
|
433
657
|
def applicable_filters
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
658
|
+
@applicable_filters ||= begin
|
|
659
|
+
given = ignore_unknown_filters ? definition.applicable(filters) : filters
|
|
660
|
+
single_value? ? given : given.reject { |key, _| key.to_s.start_with?(ransack_name) }
|
|
661
|
+
end
|
|
437
662
|
end
|
|
438
663
|
end
|
|
439
664
|
end
|
|
@@ -40,9 +40,9 @@ module Janela
|
|
|
40
40
|
@panes = []
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
def pane(model, measure, by: nil, granularity: nil, limit: nil, on: nil)
|
|
43
|
+
def pane(model, measure, by: nil, granularity: nil, limit: nil, sort: nil, on: nil)
|
|
44
44
|
query = Query.new(definition: model.janela, measure: measure, dimension: by,
|
|
45
|
-
granularity: granularity, limit: limit, filters: filters)
|
|
45
|
+
granularity: granularity, limit: limit, sort: sort, filters: filters)
|
|
46
46
|
panes << query.lookup_key.merge("result" => plain(query.result(on: on)))
|
|
47
47
|
end
|
|
48
48
|
|
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
<%= link_to t("janela.actions.edit"), edit_frame_path(@frame) %>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
|
-
<%# charts
|
|
9
|
-
<%= janela_frame @frame, charts:
|
|
8
|
+
<%# A table unless the host has said its layout draws charts, because this page cannot see that (ADR 018, ADR 059). %>
|
|
9
|
+
<%= janela_frame @frame, charts: Janela.charts_on_own_pages %>
|
|
@@ -41,6 +41,13 @@
|
|
|
41
41
|
<%= form.select :limit, Janela::Pane::OFFERED_LIMITS, include_blank: t("janela.panes.no_limit") %>
|
|
42
42
|
</div>
|
|
43
43
|
|
|
44
|
+
<%# Blank is largest first, so desc is not offered a second time. %>
|
|
45
|
+
<div class="janela-field">
|
|
46
|
+
<%= form.label :sort %>
|
|
47
|
+
<%= form.select :sort, (Janela.sorts - %w[desc]).map { |value| [ t("janela.sorts.#{value}"), value ] }, include_blank: t("janela.sorts.desc") %>
|
|
48
|
+
<span class="janela-hint"><%= t("janela.sorts.hint") %></span>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
44
51
|
<%# What may sit beside the label: this model's other measures and its
|
|
45
52
|
categorical dimensions. The pane's own choices are left out when they are
|
|
46
53
|
already made (ADR 051). %>
|
|
@@ -56,6 +63,46 @@
|
|
|
56
63
|
<span class="janela-hint"><%= t("janela.companions.hint") %></span>
|
|
57
64
|
</div>
|
|
58
65
|
|
|
66
|
+
<%# A running total and further series apply to a time pane (ADR 062). The
|
|
67
|
+
series slots are plain selects, one blank beyond those the row holds, up
|
|
68
|
+
to the most a pane may carry, since these pages have no JavaScript to
|
|
69
|
+
add a row on demand (ADR 018). Each slot leads with its measure, which
|
|
70
|
+
is how the browser's params keep one slot's fields together. %>
|
|
71
|
+
<% if granularity_applies %>
|
|
72
|
+
<div class="janela-field">
|
|
73
|
+
<%= form.check_box :cumulative %>
|
|
74
|
+
<%= form.label :cumulative %>
|
|
75
|
+
<span class="janela-hint"><%= t("janela.series.cumulative_hint") %></span>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<%
|
|
79
|
+
slots = Array(pane.series).first(Janela::Query::MAX_SERIES)
|
|
80
|
+
slots << {} if slots.size < Janela::Query::MAX_SERIES
|
|
81
|
+
time_choices = definition.dimensions.values.select(&:time?).map { |dimension| [ dimension.name.to_s.humanize, dimension.name ] }
|
|
82
|
+
%>
|
|
83
|
+
<fieldset class="janela-field janela-series">
|
|
84
|
+
<legend><%= Janela::Pane.human_attribute_name(:series) %></legend>
|
|
85
|
+
<span class="janela-hint"><%= t("janela.series.hint", count: Janela::Query::MAX_SERIES) %></span>
|
|
86
|
+
<% slots.each_with_index do |slot, index| %>
|
|
87
|
+
<% slot = slot.to_h.stringify_keys %>
|
|
88
|
+
<div class="janela-series-slot">
|
|
89
|
+
<%= label_tag "pane_series_#{index}_measure", t("janela.series.measure") %>
|
|
90
|
+
<%= select_tag "pane[series][][measure]", options_for_select(measures, slot["measure"]), include_blank: t("janela.series.none"), id: "pane_series_#{index}_measure" %>
|
|
91
|
+
<%= label_tag "pane_series_#{index}_by", t("janela.series.by") %>
|
|
92
|
+
<%= select_tag "pane[series][][by]", options_for_select(time_choices, slot["by"]), include_blank: t("janela.series.same_as_pane"), id: "pane_series_#{index}_by" %>
|
|
93
|
+
<%= check_box_tag "pane[series][][cumulative]", "1", ActiveModel::Type::Boolean.new.cast(slot["cumulative"]), id: "pane_series_#{index}_cumulative" %>
|
|
94
|
+
<%= label_tag "pane_series_#{index}_cumulative", Janela::Pane.human_attribute_name(:cumulative) %>
|
|
95
|
+
<%= label_tag "pane_series_#{index}_as", t("janela.series.as") %>
|
|
96
|
+
<%= select_tag "pane[series][][as]", options_for_select(Janela::Query::CANVAS.map { |each| [ t("janela.renderers.#{each}"), each ] }, slot["as"]), include_blank: t("janela.series.same_as_pane"), id: "pane_series_#{index}_as" %>
|
|
97
|
+
<%= label_tag "pane_series_#{index}_axis", t("janela.series.axis") %>
|
|
98
|
+
<%= select_tag "pane[series][][axis]", options_for_select(Janela::Query::AXES.map { |each| [ t("janela.series.axes.#{each}"), each ] }, slot["axis"]), id: "pane_series_#{index}_axis" %>
|
|
99
|
+
<%= label_tag "pane_series_#{index}_title", Janela::Pane.human_attribute_name(:title) %>
|
|
100
|
+
<%= text_field_tag "pane[series][][title]", slot["title"], id: "pane_series_#{index}_title", placeholder: t("janela.panes.title_placeholder") %>
|
|
101
|
+
</div>
|
|
102
|
+
<% end %>
|
|
103
|
+
</fieldset>
|
|
104
|
+
<% end %>
|
|
105
|
+
|
|
59
106
|
<div class="janela-field">
|
|
60
107
|
<%= form.label :height %>
|
|
61
108
|
<%= form.select :height, Janela::Pane::HEIGHTS.map { |step| [ t("janela.heights.#{step}"), step ] }, include_blank: t("janela.heights.automatic") %>
|
|
@@ -72,6 +119,12 @@
|
|
|
72
119
|
<span class="janela-hint"><%= t("janela.value_labels.hint") %></span>
|
|
73
120
|
</div>
|
|
74
121
|
|
|
122
|
+
<div class="janela-field">
|
|
123
|
+
<%= form.check_box :ignore_unknown_filters %>
|
|
124
|
+
<%= form.label :ignore_unknown_filters %>
|
|
125
|
+
<span class="janela-hint"><%= t("janela.ignore_unknown_filters.hint") %></span>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
75
128
|
<div class="janela-field">
|
|
76
129
|
<%= form.label :span %>
|
|
77
130
|
<%= form.select :span, Janela::Pane::SPANS.to_a %>
|