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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +26 -8
  4. data/UPGRADING.md +59 -0
  5. data/app/assets/javascripts/janela/chart_controller.js +148 -42
  6. data/app/assets/stylesheets/janela.css +7 -0
  7. data/app/controllers/janela/panes_controller.rb +2 -2
  8. data/app/controllers/janela/queries_controller.rb +12 -0
  9. data/app/controllers/janela/snapshot_queries_controller.rb +2 -0
  10. data/app/helpers/janela/frames_helper.rb +37 -9
  11. data/app/models/janela/pane.rb +33 -2
  12. data/app/models/janela/query.rb +252 -27
  13. data/app/models/janela/snapshot.rb +2 -2
  14. data/app/views/janela/frames/show.html.erb +2 -2
  15. data/app/views/janela/panes/_form.html.erb +53 -0
  16. data/app/views/janela/queries/_query.html.erb +18 -5
  17. data/config/locales/en.yml +26 -1
  18. data/db/migrate/20261013000001_add_sort_to_janela_panes.rb +7 -0
  19. data/db/migrate/20261029000001_add_series_to_janela_panes.rb +9 -0
  20. data/db/migrate/20261029000003_add_ignore_unknown_filters_to_janela_panes.rb +8 -0
  21. data/docs/agents.md +33 -2
  22. data/docs/decisions/018-a-table-is-the-universal-renderer.md +1 -0
  23. data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +1 -0
  24. data/docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md +1 -1
  25. data/docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md +204 -0
  26. data/docs/decisions/056-the-demo-counts-its-own-visits-and-an-agent-arranges-the-dashboard.md +51 -0
  27. data/docs/decisions/057-a-host-is-told-about-the-agent-tools-and-a-generator-sets-them-up.md +197 -0
  28. data/docs/decisions/058-the-deployed-demo-is-reached-over-ssh-and-its-visits-frame-survives-a-reset.md +94 -0
  29. data/docs/decisions/059-a-host-can-tell-janelas-own-pages-that-its-layout-draws-charts.md +91 -0
  30. data/docs/decisions/060-the-demo-tells-search-engines-what-it-is-and-shows-its-own-adoption.md +79 -0
  31. data/docs/decisions/061-the-demo-speaks-brazilian-portuguese-under-a-prefix-and-the-gem-stays-english.md +118 -0
  32. data/docs/decisions/062-a-time-pane-can-accumulate-and-carry-more-series-on-two-axes.md +282 -0
  33. data/docs/decisions/063-a-pane-can-ignore-a-filter-its-model-cannot-apply.md +147 -0
  34. data/docs/decisions/INDEX.md +25 -16
  35. data/docs/roadmap.md +10 -4
  36. data/lib/generators/janela/mcp/mcp_generator.rb +51 -0
  37. data/lib/generators/janela/mcp/templates/janela-mcp.tt +50 -0
  38. data/lib/janela/definition.rb +73 -4
  39. data/lib/janela/dimension.rb +9 -1
  40. data/lib/janela/doctor.rb +10 -1
  41. data/lib/janela/measure.rb +8 -0
  42. data/lib/janela/tools.rb +37 -4
  43. data/lib/janela/version.rb +1 -1
  44. data/lib/janela.rb +12 -0
  45. 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
- def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, id: nil)
38
- query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, height: height, prominence: prominence,
39
- value_labels: (1 if value_labels), companions: companions.presence,
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
- def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil, height: nil, prominence: nil, value_labels: nil, companions: nil)
52
- query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, height: height,
53
- prominence: prominence, value_labels: (1 if value_labels), companions: companions.presence }.compact
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).
@@ -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, filters: filters, fixed: fixed,
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
@@ -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
- attr_reader :definition, :measure, :dimension, :renderer, :limit, :height, :prominence, :value_labels, :companions, :filters, :fixed, :default, :snapshot
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
- def self.turbo_frame_id(model:, measure:, by: nil, as: :table, granularity: nil, limit: nil, snapshot: nil)
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
- def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
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 = value_labels!(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
- # Only a bar has a bar to put a value on. A line, a ring, a table and a
125
- # single value ignore the flag, so a pane switched between renderers keeps
126
- # it (ADR 054).
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? && renderer == "bar"
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 = "#{measure.to_s.humanize} by #{dimension.to_s.humanize}"
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
- return snapshot.stored_result(self) if frozen?
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
- measure.to_s.humanize
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
- series = definition.series(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity)
344
- @buckets = series.keys.to_h { |bucket| [ dimension_definition.label(bucket, granularity), bucket ] }
345
- series.transform_keys { |bucket| dimension_definition.label(bucket, granularity) }
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 value_labels!(value)
412
- case value.to_s
413
- when "", "0", "false" then false
414
- when "1", "true" then true
415
- else raise BadRequest, "value_labels must be 1 or 0, got #{value.inspect}"
416
- end
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
- return filters if single_value?
435
-
436
- filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
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: false because this page loads no chart runtime (ADR 018). %>
9
- <%= janela_frame @frame, charts: false %>
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 %>