janela 0.10.0 → 0.12.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +31 -9
  4. data/UPGRADING.md +73 -0
  5. data/app/assets/javascripts/janela/chart_controller.js +64 -19
  6. data/app/assets/javascripts/janela/frame_controller.js +44 -14
  7. data/app/assets/stylesheets/janela.css +64 -1
  8. data/app/controllers/janela/panes_controller.rb +2 -2
  9. data/app/controllers/janela/queries_controller.rb +3 -0
  10. data/app/controllers/janela/snapshot_queries_controller.rb +3 -0
  11. data/app/helpers/janela/frames_helper.rb +12 -4
  12. data/app/models/janela/pane.rb +33 -2
  13. data/app/models/janela/query.rb +251 -10
  14. data/app/views/janela/panes/_form.html.erb +25 -0
  15. data/app/views/janela/queries/_query.html.erb +38 -13
  16. data/app/views/janela/queries/_ring.html.erb +41 -0
  17. data/config/locales/en.yml +25 -0
  18. data/db/migrate/20260930000001_add_height_to_janela_panes.rb +7 -0
  19. data/db/migrate/20260930000002_add_prominence_to_janela_panes.rb +7 -0
  20. data/db/migrate/20260930000003_add_companions_to_janela_panes.rb +8 -0
  21. data/docs/decisions/006-time-dimensions-with-groupdate.md +1 -1
  22. data/docs/decisions/024-selecting-more-than-one-value.md +1 -1
  23. data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +1 -1
  24. data/docs/decisions/044-a-categorical-dimension-can-say-what-it-excludes.md +140 -0
  25. data/docs/decisions/045-clicking-a-time-bucket-filters-the-frame-to-its-range.md +206 -0
  26. data/docs/decisions/046-a-ring-is-server-drawn-svg-and-a-palette-is-eight-fixed-colours.md +199 -0
  27. data/docs/decisions/047-a-charts-height-is-one-of-five-steps.md +169 -0
  28. data/docs/decisions/048-a-frame-can-be-told-to-refresh-and-janela-never-decides-when.md +154 -0
  29. data/docs/decisions/049-the-null-group-is-one-more-value-in-a-selection.md +143 -0
  30. data/docs/decisions/050-a-single-values-prominence-is-one-of-three-steps.md +124 -0
  31. data/docs/decisions/051-a-table-can-carry-companion-columns.md +160 -0
  32. data/docs/decisions/052-how-the-project-is-run.md +93 -0
  33. data/docs/decisions/INDEX.md +28 -19
  34. data/docs/roadmap.md +25 -45
  35. data/docs/theming.md +18 -2
  36. data/lib/janela/definition.rb +91 -7
  37. data/lib/janela/dimension.rb +38 -2
  38. data/lib/janela/engine.rb +13 -1
  39. data/lib/janela/measure.rb +56 -7
  40. data/lib/janela/version.rb +1 -1
  41. metadata +19 -6
@@ -26,8 +26,15 @@ module Janela
26
26
  # so a host that changes the query in place (a renderer, granularity or
27
27
  # limit control) keeps one stable frame for Turbo to reconcile into
28
28
  # rather than a different id every time the query changes (ADR 029).
29
- def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, id: nil)
30
- query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit,
29
+ #
30
+ # prominence: is one of three steps for a single value, and height: one of
31
+ # five for a chart. Both travel in the pane's URL for the same reason.
32
+ # height: is one of five steps, and travels in the pane's URL because the
33
+ # server draws the pane from it again on every cross-filter. It is not part
34
+ # of the frame's id: how tall a pane is does not say which query it is
35
+ # (ADR 047, ADR 029).
36
+ def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, height: nil, prominence: nil, companions: nil, id: nil)
37
+ query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, height: height, prominence: prominence, companions: companions.presence,
31
38
  where: @janela_fixed_filters.presence }.compact
32
39
  base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
33
40
 
@@ -39,8 +46,9 @@ module Janela
39
46
 
40
47
  # A pane as it was when the snapshot was taken: same shape as janela_pane,
41
48
  # not part of the live frame's filter state (ADR 009).
42
- def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil)
43
- query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit }.compact
49
+ def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil, height: nil, prominence: nil, companions: nil)
50
+ query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, height: height,
51
+ prominence: prominence, companions: companions.presence }.compact
44
52
  src = janela_routes.snapshot_pane_path(snapshot, model.model_name.route_key, measure, by, **query)
45
53
 
46
54
  turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, snapshot: snapshot),
@@ -5,6 +5,14 @@ module Janela
5
5
  # invent a query or reach a model nobody exposed.
6
6
  class Pane < ActiveRecord::Base
7
7
  SPANS = (1..12).freeze
8
+
9
+ # How tall a bar or line chart is drawn, or nil for what it always was
10
+ # (ADR 047). Five steps rather than pixels: a stored integer selects a class
11
+ # that is already written (ADR 016).
12
+ HEIGHTS = Query::HEIGHTS
13
+
14
+ # How prominent a single value is drawn (ADR 050).
15
+ PROMINENCES = Query::PROMINENCES
8
16
  LIMITS = (1..1000).freeze
9
17
  # A form cannot offer a thousand options, and these are the row counts a
10
18
  # dashboard actually asks for. Any limit inside LIMITS is still valid.
@@ -30,6 +38,10 @@ module Janela
30
38
  validates :kind, inclusion: { in: KINDS }
31
39
  validates :span, inclusion: { in: SPANS }
32
40
  validates :limit, inclusion: { in: LIMITS }, allow_nil: true
41
+ validates :height, inclusion: { in: HEIGHTS }, allow_nil: true
42
+ validates :prominence, inclusion: { in: PROMINENCES }, allow_nil: true
43
+ before_validation :normalise_companions
44
+ validate :companions_are_declared
33
45
  validates :measure, presence: true, if: :query?
34
46
  validate :declared_by_a_janela_block, if: :query?
35
47
  validate :holds_no_query, unless: :query?
@@ -72,7 +84,7 @@ module Janela
72
84
  # instead (ADR 018).
73
85
  def query(filters: {}, fixed: {}, renderer: self.renderer)
74
86
  Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
75
- renderer: renderer, granularity: granularity, limit: limit, filters: filters, fixed: fixed,
87
+ renderer: renderer, granularity: granularity, limit: limit, height: height, prominence: prominence, companions: companions, filters: filters, fixed: fixed,
76
88
  default: frame.default_for(definition.model), title: title)
77
89
  end
78
90
 
@@ -99,7 +111,7 @@ module Janela
99
111
  end
100
112
 
101
113
  def chart?
102
- Query::RENDERERS.include?(renderer.to_s) && renderer.to_s != "table"
114
+ Query::CANVAS.include?(renderer.to_s)
103
115
  end
104
116
 
105
117
  def definition
@@ -107,6 +119,25 @@ module Janela
107
119
  end
108
120
 
109
121
  private
122
+ # A multiple select sends an empty string beside its choices, and an empty
123
+ # selection is nothing rather than an empty list.
124
+ def normalise_companions
125
+ self.companions = Array(companions).map(&:to_s).reject(&:blank?).presence
126
+ end
127
+
128
+ # The same rules a URL is held to, from the same place, so a row cannot
129
+ # name what a request could not (ADR 051).
130
+ def companions_are_declared
131
+ return if companions.blank? || !query? || measure.blank? || Query::RENDERERS.exclude?(renderer.to_s)
132
+
133
+ Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
134
+ renderer: renderer, companions: companions)
135
+ rescue Janela::BadRequest => error
136
+ errors.add(:companions, error.message)
137
+ rescue Janela::Error
138
+ nil # an unknown model is reported by its own validation
139
+ end
140
+
110
141
  def panes_above
111
142
  frame.panes.where(position: ...position).order(:position)
112
143
  end
@@ -5,9 +5,35 @@ module Janela
5
5
  # rather than collapsing this one to the value clicked. A query read from a
6
6
  # snapshot shows stored results and cannot be clicked at all.
7
7
  class Query
8
- RENDERERS = %w[table bar line].freeze
8
+ RENDERERS = %w[table bar line doughnut pie].freeze
9
9
 
10
- attr_reader :definition, :measure, :dimension, :renderer, :limit, :filters, :fixed, :default, :snapshot
10
+ # Drawn by Chart.js on a canvas, and so blank without a chart runtime.
11
+ CANVAS = %w[bar line].freeze
12
+
13
+ # Drawn on the server as SVG instead (ADR 046).
14
+ RINGS = %w[doughnut pie].freeze
15
+
16
+ # Palette slots before the neutral. Colour is chosen by position and never
17
+ # cycled: a ninth category drawn in the first colour would be two
18
+ # categories the same beside a legend that says they differ (ADR 046).
19
+ SERIES = 8
20
+
21
+ # The steps a chart's height may take (ADR 047). Pane::HEIGHTS is the same
22
+ # range for a stored row, and a test holds the two together.
23
+ HEIGHTS = (1..5).freeze
24
+
25
+ # The steps a single value's prominence may take (ADR 050).
26
+ PROMINENCES = (1..3).freeze
27
+
28
+ # Each companion column is a query of its own, so how many a pane may run
29
+ # is bounded, as what one query may ask for is (ADR 051, ADR 025).
30
+ MAX_COMPANIONS = 3
31
+
32
+ # A column beside a table's label: a measure's formatted numbers, or a
33
+ # dimension's shared fact, by label.
34
+ Companion = Struct.new(:name, :header, :fact, :cells, keyword_init: true)
35
+
36
+ attr_reader :definition, :measure, :dimension, :renderer, :limit, :height, :prominence, :companions, :filters, :fixed, :default, :snapshot
11
37
 
12
38
  # The helper renders the turbo frame and the controller renders its
13
39
  # replacement, so both derive the id the same way from the same parameters.
@@ -17,7 +43,7 @@ module Janela
17
43
  parts.compact.join("_")
18
44
  end
19
45
 
20
- def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
46
+ def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, height: nil, prominence: nil, companions: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
21
47
  @definition = definition
22
48
  @title = title
23
49
  @measure = measure
@@ -31,6 +57,9 @@ module Janela
31
57
  raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
32
58
  @granularity = Dimension.granularity!(granularity) if granularity.present?
33
59
  @limit = definition.limit!(limit) if limit.present?
60
+ @height = height!(height) if height.present?
61
+ @prominence = prominence!(prominence) if prominence.present?
62
+ @companions = companions!(companions)
34
63
  end
35
64
 
36
65
  def model
@@ -60,15 +89,101 @@ module Janela
60
89
  @granularity || (dimension_definition.granularity if time?)
61
90
  end
62
91
 
63
- # Clicking a category adds one Ransack condition; clicking a time bucket
64
- # would need two, and the dashboard toggles one key at a time (ADR 006).
65
92
  # A stored pane is the record of a moment and is not clickable (ADR 009).
93
+ # A time pane is: a bucket writes the pair of conditions for its range
94
+ # (ADR 045).
66
95
  def clickable?
67
- !single_value? && !time? && !frozen?
96
+ !single_value? && !frozen?
68
97
  end
69
98
 
70
99
  def chart?
71
- !single_value? && renderer != "table"
100
+ !single_value? && CANVAS.include?(renderer)
101
+ end
102
+
103
+ # A height means a box only where there is a canvas to fill it. A ring, a
104
+ # table and a single value ignore one, so switching a pane between
105
+ # renderers never invalidates it (ADR 047).
106
+ # Only a single value has a headline number to make more or less of. A
107
+ # table, a chart and a ring ignore one, so a pane switched between renderers
108
+ # keeps what it had (ADR 050).
109
+ def prominent?
110
+ single_value? && !prominence.nil?
111
+ end
112
+
113
+ def boxed?
114
+ chart? && !height.nil?
115
+ end
116
+
117
+ def ring?
118
+ !single_value? && RINGS.include?(renderer)
119
+ end
120
+
121
+ # A part of a whole cannot be negative, and a ring of nothing draws
122
+ # nothing. Either way the pane is a table that says why, not a wrong
123
+ # picture (ADR 046).
124
+ def hole?
125
+ renderer == "doughnut"
126
+ end
127
+
128
+ def ringable?(result)
129
+ values = result.values.map(&:to_f)
130
+ values.none?(&:negative?) && values.sum.positive?
131
+ end
132
+
133
+ # The custom property a category at this position is drawn with.
134
+ def series_property(index)
135
+ index < SERIES ? "--janela-series-#{index + 1}" : "--janela-series-other"
136
+ end
137
+
138
+ # One entry per label, in result order, with the arc it covers. A zero is
139
+ # kept for the legend and has no arc. The filter is the one a click on
140
+ # that label toggles, so the slice and its legend row cannot disagree.
141
+ CENTRE = 50.0
142
+ OUTER = 48.0
143
+ INNER = 27.0
144
+
145
+ Slice = Struct.new(:label, :formatted, :property, :click, :selected, :fraction, :from, keyword_init: true) do
146
+ # The SVG path of this slice on a 100 by 100 canvas, from twelve o'clock
147
+ # clockwise, or nil when it covers nothing. One slice covering the whole
148
+ # circle is two half arcs, since an arc from a point to itself is not
149
+ # drawn. A doughnut's hole is a second sub-path, cut out by the
150
+ # stylesheet's even-odd fill rule.
151
+ def path(hole:)
152
+ return unless fraction.positive?
153
+ return whole(hole) if fraction >= 0.9999
154
+
155
+ large = fraction > 0.5 ? 1 : 0
156
+ outer_from, outer_to = point(OUTER, from), point(OUTER, from + fraction)
157
+ if hole
158
+ inner_from, inner_to = point(INNER, from), point(INNER, from + fraction)
159
+ "M #{outer_from} A #{OUTER} #{OUTER} 0 #{large} 1 #{outer_to} L #{inner_to} A #{INNER} #{INNER} 0 #{large} 0 #{inner_from} Z"
160
+ else
161
+ "M #{CENTRE} #{CENTRE} L #{outer_from} A #{OUTER} #{OUTER} 0 #{large} 1 #{outer_to} Z"
162
+ end
163
+ end
164
+
165
+ private
166
+ def whole(hole)
167
+ circle = ->(radius) { "M #{CENTRE} #{CENTRE - radius} A #{radius} #{radius} 0 1 1 #{CENTRE} #{CENTRE + radius} A #{radius} #{radius} 0 1 1 #{CENTRE} #{CENTRE - radius} Z" }
168
+ hole ? "#{circle.(OUTER)} #{circle.(INNER)}" : circle.(OUTER)
169
+ end
170
+
171
+ def point(radius, turns)
172
+ angle = turns * 2 * Math::PI - Math::PI / 2
173
+ format("%.3f %.3f", CENTRE + radius * Math.cos(angle), CENTRE + radius * Math.sin(angle))
174
+ end
175
+ end
176
+
177
+ def slices(result)
178
+ total = result.values.sum(&:to_f)
179
+ from = 0.0
180
+ result.each_with_index.map do |(label, measured), index|
181
+ fraction = measured.to_f / total
182
+ slice = Slice.new(label: label.to_s, formatted: format(measured), property: series_property(index),
183
+ click: (click_data(label) if clickable?), selected: selected?(label), fraction: fraction, from: from)
184
+ from += fraction
185
+ slice
186
+ end
72
187
  end
73
188
 
74
189
  def turbo_frame_id
@@ -105,7 +220,48 @@ module Janela
105
220
  # selection, which this pane shows the alternatives to (ADR 040, 043).
106
221
  on = definition.narrow(on || model.all, default) if default.present?
107
222
  on = definition.narrow(on || model.all, fixed) if fixed.present?
108
- definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
223
+ primary = time? ? time_result(on) : definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
224
+ @companion_columns = companions? ? fetch_companions(primary, on) : []
225
+ primary
226
+ end
227
+
228
+ # Only a table draws companion columns, and a stored pane is the record of
229
+ # a moment that holds none (ADR 051).
230
+ def companions?
231
+ renderer == "table" && !single_value? && !frozen? && companions.any?
232
+ end
233
+
234
+ # The columns beside the label, once a result has been read. Kept from the
235
+ # same read as the result, with the same scope and filters, the way a time
236
+ # pane keeps its buckets.
237
+ def companion_columns
238
+ @companion_columns || []
239
+ end
240
+
241
+ def dimension_header
242
+ dimension.to_s.humanize
243
+ end
244
+
245
+ def measure_header
246
+ measure.to_s.humanize
247
+ end
248
+
249
+ # The two filters a click on this label writes, for a time pane: the start
250
+ # of its bucket and the start of the next (ADR 045).
251
+ def range_for(label)
252
+ from, to = dimension_definition.bounds(@buckets.fetch(label.to_s), granularity)
253
+ { "#{ransack_name}_gteq" => from, "#{ransack_name}_lt" => to }
254
+ end
255
+
256
+ # The data attributes a table button or legend row carries: one key and
257
+ # value for a category, the pair of conditions for a bucket.
258
+ def click_data(label)
259
+ if time?
260
+ { janela__frame_filters_param: range_for(label).to_json }
261
+ else
262
+ key, value = filter_params(label)
263
+ key ? { janela__frame_key_param: key, janela__frame_value_param: value } : nil
264
+ end
109
265
  end
110
266
 
111
267
  # The Ransack key and value a click on this label should toggle. A null
@@ -116,7 +272,7 @@ module Janela
116
272
  # _eq link is still read, since a URL somebody already sent should not stop
117
273
  # working to suit us (ADR 024).
118
274
  def filter_params(label)
119
- return [ nil, nil ] unless clickable?
275
+ return [ nil, nil ] unless clickable? && !time?
120
276
  return [ "#{ransack_name}_null", "1" ] if label.to_s == Dimension::NONE
121
277
 
122
278
  [ "#{ransack_name}_in", label.to_s ]
@@ -126,6 +282,7 @@ module Janela
126
282
  # to look up by label when a bar is clicked.
127
283
  def filters_for(labels)
128
284
  return {} unless clickable?
285
+ return labels.to_h { |label| [ label.to_s, range_for(label) ] } if time?
129
286
 
130
287
  labels.to_h { |label| [ label.to_s, filter_params(label) ] }
131
288
  end
@@ -137,6 +294,7 @@ module Janela
137
294
  # like any other label.
138
295
  def selected_values
139
296
  return [] unless clickable?
297
+ return selected_buckets if time?
140
298
 
141
299
  values = Array(filter("#{ransack_name}_in")) + Array(filter("#{ransack_name}_eq"))
142
300
  values = values.map(&:to_s)
@@ -161,8 +319,91 @@ module Janela
161
319
  dimension_definition.ransack_name
162
320
  end
163
321
 
322
+ # A time pane's series is read as buckets, not labels, and the buckets
323
+ # are kept: the labels alone cannot say what range a click covers.
324
+ def time_result(on)
325
+ series = definition.series(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity)
326
+ @buckets = series.keys.to_h { |bucket| [ dimension_definition.label(bucket, granularity), bucket ] }
327
+ series.transform_keys { |bucket| dimension_definition.label(bucket, granularity) }
328
+ end
329
+
330
+ # The buckets that lie wholly inside the range the filters name. Janela
331
+ # writes both ends, so a range with one is somebody else's and selects
332
+ # nothing here. Read before the first result there are no buckets yet.
333
+ def selected_buckets
334
+ from, to = filter("#{ransack_name}_gteq"), filter("#{ransack_name}_lt")
335
+ return [] if from.blank? || to.blank? || @buckets.nil?
336
+
337
+ from, to = Time.zone.parse(from.to_s), Time.zone.parse(to.to_s)
338
+ return [] if from.nil? || to.nil?
339
+
340
+ @buckets.select { |_, bucket|
341
+ start, finish = dimension_definition.span(bucket, granularity)
342
+ start >= from && finish <= to
343
+ }.keys
344
+ end
345
+
346
+ def companions!(value)
347
+ return [] if value.blank?
348
+ raise BadRequest, "companions must be a list of measure and dimension names, got #{value.class}" unless value.is_a?(Array) && value.all? { |each| each.is_a?(String) || each.is_a?(Symbol) }
349
+
350
+ names = value.map(&:to_sym)
351
+ raise BadRequest, "a pane may carry at most #{MAX_COMPANIONS} companions, got #{names.size}" if names.size > MAX_COMPANIONS
352
+ raise BadRequest, "companions must each be named once, got #{names.map(&:inspect).join(', ')}" unless names == names.uniq
353
+
354
+ names.each { |name| companion!(name) }
355
+ end
356
+
357
+ # Only what the model declared, by the name it declared it under: no
358
+ # column, no expression, nothing that becomes SQL (ADR 025, ADR 051).
359
+ def companion!(name)
360
+ declared = definition.measures.keys + definition.dimensions.keys
361
+ raise BadRequest, "#{name.inspect} is not a declared measure or dimension of #{model}. Declared: #{declared.join(', ')}" unless declared.include?(name)
362
+ raise BadRequest, "#{name.inspect} is this pane's own #{name == measure ? 'measure' : 'dimension'}" if name == measure || name == dimension
363
+ return if definition.measures.key?(name)
364
+
365
+ fact = definition.dimensions.fetch(name)
366
+ raise BadRequest, "#{name.inspect} is a time dimension, and a bucket is not a fact about a label" if fact.time?
367
+ raise BadRequest, "#{name.inspect} is a dimension, which a time pane has no shared fact to show" if dimension && definition.dimension!(dimension).time?
368
+ end
369
+
370
+ # Ordering and the limit belong to the primary measure. The companions are
371
+ # fetched for the labels it chose, one grouped query for each measure and
372
+ # one for all the dimensions (ADR 051).
373
+ def fetch_companions(primary, on)
374
+ keys = time? ? nil : primary.keys
375
+ facts = companions.reject { |name| definition.measures.key?(name) }
376
+ shared = facts.any? ? definition.facts(facts, by: dimension, where: applicable_filters, on: on, keys: keys) : {}
377
+
378
+ companions.map do |name|
379
+ if definition.measures.key?(name)
380
+ values = definition.query(name, by: dimension, where: applicable_filters, on: on, granularity: granularity, keys: keys)
381
+ formatted = definition.measure!(name)
382
+ Companion.new(name: name, header: name.to_s.humanize, fact: false,
383
+ cells: primary.keys.to_h { |label| [ label, formatted.format(values[label]) ] })
384
+ else
385
+ Companion.new(name: name, header: name.to_s.humanize, fact: true,
386
+ cells: primary.keys.to_h { |label| [ label, shared.dig(label, name).to_s ] })
387
+ end
388
+ end
389
+ end
390
+
391
+ def prominence!(value)
392
+ step = Integer(value.to_s, exception: false)
393
+ raise BadRequest, "prominence must be a whole number from #{PROMINENCES.first} to #{PROMINENCES.last}, got #{value.inspect}" unless PROMINENCES.cover?(step)
394
+
395
+ step
396
+ end
397
+
398
+ def height!(value)
399
+ step = Integer(value.to_s, exception: false)
400
+ raise BadRequest, "height must be a whole number from #{HEIGHTS.first} to #{HEIGHTS.last}, got #{value.inspect}" unless HEIGHTS.cover?(step)
401
+
402
+ step
403
+ end
404
+
164
405
  def applicable_filters
165
- return filters if single_value? || time?
406
+ return filters if single_value?
166
407
 
167
408
  filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
168
409
  end
@@ -41,6 +41,31 @@
41
41
  <%= form.select :limit, Janela::Pane::OFFERED_LIMITS, include_blank: t("janela.panes.no_limit") %>
42
42
  </div>
43
43
 
44
+ <%# What may sit beside the label: this model's other measures and its
45
+ categorical dimensions. The pane's own choices are left out when they are
46
+ already made (ADR 051). %>
47
+ <%
48
+ companion_choices = {
49
+ t("janela.companions.measures") => definition.measures.keys.reject { |name| name.to_s == pane.measure }.map { |name| [ name.to_s.humanize, name ] },
50
+ t("janela.companions.dimensions") => definition.dimensions.values.reject { |each| each.time? || each.name.to_s == pane.dimension }.map { |each| [ each.name.to_s.humanize, each.name ] }
51
+ }
52
+ %>
53
+ <div class="janela-field">
54
+ <%= form.label :companions %>
55
+ <%= form.select :companions, companion_choices, {}, multiple: true %>
56
+ <span class="janela-hint"><%= t("janela.companions.hint") %></span>
57
+ </div>
58
+
59
+ <div class="janela-field">
60
+ <%= form.label :height %>
61
+ <%= form.select :height, Janela::Pane::HEIGHTS.map { |step| [ t("janela.heights.#{step}"), step ] }, include_blank: t("janela.heights.automatic") %>
62
+ </div>
63
+
64
+ <div class="janela-field">
65
+ <%= form.label :prominence %>
66
+ <%= form.select :prominence, Janela::Pane::PROMINENCES.map { |step| [ t("janela.prominences.#{step}"), step ] }, include_blank: t("janela.prominences.automatic") %>
67
+ </div>
68
+
44
69
  <div class="janela-field">
45
70
  <%= form.label :span %>
46
71
  <%= form.select :span, Janela::Pane::SPANS.to_a %>
@@ -1,16 +1,21 @@
1
1
  <%# One pane's content, without its turbo frame: the same markup whether the
2
2
  pane came from a URL, a row, or a frame rendered inline. %>
3
3
  <% if query.single_value? %>
4
- <p class="janela-pane janela-value">
4
+ <%= tag.p class: [ "janela-pane", "janela-value", ("janela-prominence-#{query.prominence}" if query.prominent?) ] do %>
5
5
  <span class="janela-value-label"><%= query.title %></span>
6
6
  <strong class="janela-value-number"><%= query.format(result || 0) %></strong>
7
- </p>
7
+ <% end %>
8
8
  <% elsif result.empty? %>
9
9
  <p class="janela-pane janela-empty"><%= query.title %>: no data</p>
10
+ <% elsif query.ring? && query.ringable?(result) %>
11
+ <%= render "janela/queries/ring", query: query, result: result %>
10
12
  <% elsif query.chart? %>
11
13
  <% title_id = "#{query.turbo_frame_id}-title" %>
12
14
  <figure class="janela-pane">
13
15
  <figcaption class="janela-chart-title" id="<%= title_id %>"><%= query.title %></figcaption>
16
+ <%# A height puts the canvas in a box of fixed size for the chart to fill;
17
+ with none there is no box and the chart is what it always was (ADR 047). %>
18
+ <% canvas = capture do %>
14
19
  <canvas class="janela-chart"
15
20
  data-controller="janela--chart"
16
21
  data-action="janela--chart:toggle->janela--frame#toggle"
@@ -21,31 +26,51 @@
21
26
  data-janela--chart-values-value="<%= result.values.map(&:to_f).to_json %>"
22
27
  data-janela--chart-formatted-value="<%= result.values.map { |measured| query.format(measured) }.to_json %>"
23
28
  data-janela--chart-filters-value="<%= query.filters_for(result.keys).to_json %>"
29
+ <%= tag.attributes(aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) }) %>
30
+ <%= "data-janela--chart-fixed-height-value=true".html_safe if query.boxed? %>
24
31
  role="img" aria-labelledby="<%= title_id %>"></canvas>
32
+ <% end %>
33
+ <%= query.boxed? ? tag.div(canvas, class: "janela-chart-box janela-h-#{query.height}") : canvas %>
25
34
  </figure>
26
35
  <% else %>
27
- <table class="janela-pane">
36
+ <%= tag.table class: "janela-pane", aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) } do %>
28
37
  <caption><%= query.title %></caption>
38
+ <%# A header row only when there are companions to name: two columns read
39
+ without one, and a table with none is what it always was (ADR 051). %>
40
+ <% if query.companion_columns.any? %>
41
+ <thead>
42
+ <tr>
43
+ <th scope="col"><%= query.dimension_header %></th>
44
+ <th scope="col"><%= query.measure_header %></th>
45
+ <% query.companion_columns.each do |column| %>
46
+ <%= tag.th column.header, scope: "col", class: ("janela-fact" if column.fact) %>
47
+ <% end %>
48
+ </tr>
49
+ </thead>
50
+ <% end %>
29
51
  <tbody>
30
52
  <% result.each do |label, measured| %>
31
- <% key, value = query.filter_params(label) %>
53
+ <% click = query.click_data(label) if query.clickable? %>
32
54
  <tr>
33
55
  <td>
34
- <% if key %>
35
- <button type="button"
36
- aria-pressed="<%= query.selected?(label) %>"
37
- data-action="janela--frame#toggle"
38
- data-janela--frame-key-param="<%= key %>"
39
- data-janela--frame-value-param="<%= value %>">
40
- <%= label %>
41
- </button>
56
+ <% if click %>
57
+ <%= tag.button label, type: "button", aria: { pressed: query.selected?(label) },
58
+ data: { action: "janela--frame#toggle" }.merge(click) %>
42
59
  <% else %>
43
60
  <span><%= label %></span>
44
61
  <% end %>
45
62
  </td>
46
63
  <td><%= query.format(measured) %></td>
64
+ <% query.companion_columns.each do |column| %>
65
+ <%= tag.td column.cells[label], class: ("janela-fact" if column.fact) %>
66
+ <% end %>
47
67
  </tr>
48
68
  <% end %>
49
69
  </tbody>
50
- </table>
70
+ <% end %>
71
+ <%# A ring cannot draw a negative part or a total of nothing, so it says it
72
+ is a table rather than drawing something wrong (ADR 046). %>
73
+ <% if query.ring? %>
74
+ <p class="janela-muted"><%= t("janela.rings.not_drawable") %></p>
75
+ <% end %>
51
76
  <% end %>
@@ -0,0 +1,41 @@
1
+ <%# A doughnut or a pie, drawn here rather than on a canvas so it is in the
2
+ HTML before any JavaScript runs, prints, and can be read and operated by
3
+ a keyboard through the legend (ADR 046, ADR 026). A slice and its legend
4
+ row toggle the same filter, and the legend is the control a keyboard and a
5
+ screen reader use, so the slices are not offered to them twice. %>
6
+ <% title_id = "#{query.turbo_frame_id}-title" %>
7
+ <% slices = query.slices(result) %>
8
+ <% selecting = slices.any?(&:selected) %>
9
+ <figure class="janela-pane janela-ring">
10
+ <figcaption class="janela-chart-title" id="<%= title_id %>"><%= query.title %></figcaption>
11
+ <svg class="janela-ring-svg" viewBox="0 0 100 100" role="img" aria-labelledby="<%= title_id %>"
12
+ data-hole="<%= query.hole? %>">
13
+ <% slices.each do |slice| %>
14
+ <% path = slice.path(hole: query.hole?) %>
15
+ <% next unless path %>
16
+ <%= tag.path d: path, class: [ "janela-ring-slice", ("janela-dim" if selecting && !slice.selected) ],
17
+ style: "fill: var(#{slice.property})",
18
+ data: (slice.click ? { action: "click->janela--frame#toggle" }.merge(slice.click) : {}) do %>
19
+ <title><%= slice.label %>: <%= slice.formatted %></title>
20
+ <% end %>
21
+ <% end %>
22
+ </svg>
23
+ <table class="janela-legend">
24
+ <tbody>
25
+ <% slices.each do |slice| %>
26
+ <tr>
27
+ <td>
28
+ <span class="janela-swatch" style="background: var(<%= slice.property %>)" aria-hidden="true"></span>
29
+ <% if slice.click %>
30
+ <%= tag.button slice.label, type: "button", aria: { pressed: slice.selected },
31
+ data: { action: "janela--frame#toggle" }.merge(slice.click) %>
32
+ <% else %>
33
+ <span><%= slice.label %></span>
34
+ <% end %>
35
+ </td>
36
+ <td><%= slice.formatted %></td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+ </figure>
@@ -20,6 +20,9 @@ en:
20
20
  granularity: Granularity
21
21
  limit: Rows
22
22
  span: Width
23
+ height: Height
24
+ prominence: Prominence
25
+ companions: Beside the label
23
26
  title: Title
24
27
  janela:
25
28
  actions:
@@ -64,7 +67,29 @@ en:
64
67
  text: Text
65
68
  link_hint: "A path on this site, such as /orders. Makes the heading a link."
66
69
  title_placeholder: Janela writes one if you leave this blank
70
+ companions:
71
+ measures: Measures
72
+ dimensions: Dimensions
73
+ hint: Up to three, drawn as columns in a table. A dimension shows a value only where every row of its group shares one.
74
+ prominences:
75
+ automatic: Automatic
76
+ "1": Footnote
77
+ "2": Normal
78
+ "3": Hero
79
+ heights:
80
+ automatic: Automatic
81
+ "1": Very short
82
+ "2": Short
83
+ "3": Medium
84
+ "4": Tall
85
+ "5": Very tall
67
86
  renderers:
68
87
  bar: Bar chart
88
+ doughnut: Doughnut chart
69
89
  line: Line chart
90
+ pie: Pie chart
70
91
  table: Table
92
+ time:
93
+ click_hint: Choose a period to filter the other panes to it. Ctrl or Cmd does not add a second period.
94
+ rings:
95
+ not_drawable: A ring cannot show a negative value or a total of nothing, so this is drawn as a table.
@@ -0,0 +1,7 @@
1
+ class AddHeightToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # How tall a bar or line chart is drawn, one of five steps (ADR 047). Null
4
+ # is what every existing pane is: unset, and drawn exactly as before.
5
+ add_column :janela_panes, :height, :integer
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ class AddProminenceToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # How prominent a single value is drawn, one of three steps (ADR 050).
4
+ # Null is what every existing pane is: unset, and drawn exactly as before.
5
+ add_column :janela_panes, :prominence, :integer
6
+ end
7
+ end
@@ -0,0 +1,8 @@
1
+ class AddCompanionsToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # Measures and dimensions a table pane draws as columns beside its label
4
+ # (ADR 051), by the names the model declared them under. Null is what
5
+ # every existing pane is: none, and drawn exactly as before.
6
+ add_column :janela_panes, :companions, :json
7
+ end
8
+ end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  Date: 2026-09-15
3
- Status: Accepted
3
+ Status: Accepted (the click source paragraph is superseded by ADR 045)
4
4
  Related: ADR 002, ADR 005
5
5
  Triggers:
6
6
  - grouping a measure by a date or time column