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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +31 -9
- data/UPGRADING.md +73 -0
- data/app/assets/javascripts/janela/chart_controller.js +64 -19
- data/app/assets/javascripts/janela/frame_controller.js +44 -14
- data/app/assets/stylesheets/janela.css +64 -1
- data/app/controllers/janela/panes_controller.rb +2 -2
- data/app/controllers/janela/queries_controller.rb +3 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +3 -0
- data/app/helpers/janela/frames_helper.rb +12 -4
- data/app/models/janela/pane.rb +33 -2
- data/app/models/janela/query.rb +251 -10
- data/app/views/janela/panes/_form.html.erb +25 -0
- data/app/views/janela/queries/_query.html.erb +38 -13
- data/app/views/janela/queries/_ring.html.erb +41 -0
- data/config/locales/en.yml +25 -0
- data/db/migrate/20260930000001_add_height_to_janela_panes.rb +7 -0
- data/db/migrate/20260930000002_add_prominence_to_janela_panes.rb +7 -0
- data/db/migrate/20260930000003_add_companions_to_janela_panes.rb +8 -0
- data/docs/decisions/006-time-dimensions-with-groupdate.md +1 -1
- data/docs/decisions/024-selecting-more-than-one-value.md +1 -1
- data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +1 -1
- data/docs/decisions/044-a-categorical-dimension-can-say-what-it-excludes.md +140 -0
- data/docs/decisions/045-clicking-a-time-bucket-filters-the-frame-to-its-range.md +206 -0
- data/docs/decisions/046-a-ring-is-server-drawn-svg-and-a-palette-is-eight-fixed-colours.md +199 -0
- data/docs/decisions/047-a-charts-height-is-one-of-five-steps.md +169 -0
- data/docs/decisions/048-a-frame-can-be-told-to-refresh-and-janela-never-decides-when.md +154 -0
- data/docs/decisions/049-the-null-group-is-one-more-value-in-a-selection.md +143 -0
- data/docs/decisions/050-a-single-values-prominence-is-one-of-three-steps.md +124 -0
- data/docs/decisions/051-a-table-can-carry-companion-columns.md +160 -0
- data/docs/decisions/052-how-the-project-is-run.md +93 -0
- data/docs/decisions/INDEX.md +28 -19
- data/docs/roadmap.md +25 -45
- data/docs/theming.md +18 -2
- data/lib/janela/definition.rb +91 -7
- data/lib/janela/dimension.rb +38 -2
- data/lib/janela/engine.rb +13 -1
- data/lib/janela/measure.rb +56 -7
- data/lib/janela/version.rb +1 -1
- 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
|
-
|
|
30
|
-
|
|
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
|
|
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),
|
data/app/models/janela/pane.rb
CHANGED
|
@@ -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::
|
|
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
|
data/app/models/janela/query.rb
CHANGED
|
@@ -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
|
-
|
|
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? && !
|
|
96
|
+
!single_value? && !frozen?
|
|
68
97
|
end
|
|
69
98
|
|
|
70
99
|
def chart?
|
|
71
|
-
!single_value? && renderer
|
|
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?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<%
|
|
53
|
+
<% click = query.click_data(label) if query.clickable? %>
|
|
32
54
|
<tr>
|
|
33
55
|
<td>
|
|
34
|
-
<% if
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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>
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|