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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eac851a1f9a4eda7cac89f9ceced6664b28b26a5c889b1939147a0f5a4ed5688
4
- data.tar.gz: 5425fbb11f1585892bbc4cff9b87ddbf9f6c08a2398ec87d5cd9402e8d624d51
3
+ metadata.gz: 2416bb876bf67b3cda96853cc4cee216fd8c153a0ec4930d8243512be5c8c163
4
+ data.tar.gz: 1b4a35484a9d04d696771376389ab35dc17b76469ce772de54e48ed7017f5cc5
5
5
  SHA512:
6
- metadata.gz: 3d8563226d41987ac12140bc9af25a6492f1784937d02796c077e1282d999c132e0e19285d9f938a78b4d20e98958f28e2bd1bb55a14a6bad23e8267b5bf9a9b
7
- data.tar.gz: 0a836386550bc34bcb4e16d284592f7a8473accdbf073385eadc88e3f6b2e1da9eb14ea61394a0d0b79baa59f3f9c6e540b7f3423d6064b23da21b24f4bdc6c7
6
+ metadata.gz: 6f20d48d6dfcb473861d0e20b9116e135498d6879a79f9c5720a184b35a66be4189d7e74bc51431d116f4955c73b458af59ff428062e237ee0a2421564436ad0
7
+ data.tar.gz: 5b9c28323e356a9c6833ea25d0c0e3ef8c92225848ef7611fb4c3aeafb95bd75e7c0087d9b3957d819c161fedd1bfacd915b3731382ae78cf6874dca94202645
data/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.12.0] - 2026-10-01
9
+
10
+ ### Added
11
+
12
+ - **A table can carry companion columns.** `janela_pane Order, :expedited_rate, by: :customer, companions: [:orders, :region]`, or the same on a stored pane from the pane form, adds up to three columns beside the label, each a measure or a dimension the model declares, under a new header row. A measure companion is its own number, formatted as it declares: a rate can sit beside the count behind it, which answers whether 75% came from four reviews or forty. A dimension companion shows a value only where every row of that label's group shares one and is blank where they differ, because the alternatives were measured and wrong: grouping by it showed a label twice, and picking one showed an arbitrary value as fact. Order, limit, filters and clicking a label stay the primary measure's; only a table draws companions, a stored snapshot shows the base table, and a time pane may carry measures but not dimensions. Each companion is one more query, hence at most three. It travels in the pane URL as `companions[]=`. Stored panes need a migration; see `UPGRADING.md` (ADR 051, #34).
13
+
14
+ - **A single value can say how prominent it is.** `janela_pane Order, :orders, prominence: 3`, or the same on a stored pane from the pane form, takes one of three steps: 1 is a footnote at `1.25rem`, 2 is what every value already is at `2rem`, and 3 is the hero number at `3.5rem`. The label stays small at every step. Until now every headline number was the same size, so a host that wanted one count larger wrote CSS against `janela-value-number` for each pane, and a stored pane could not say it at all. (The issue also asked for default typography and a hideable label; both had already shipped, with #15 and #26.) With none set nothing changes, and a table, a chart and a ring ignore one. The step travels in the pane URL as `?prominence=`. Stored panes need a migration; see `UPGRADING.md` (ADR 050, #31).
15
+
16
+ - **A `ratio:` measure, for the share of rows where a boolean column is true.** `measure :expedited_rate, ratio: :expedited` reads as `31.2%` in a table cell, a single value and a chart tooltip, and orders, gap fills, cross-filters and snapshots like any other measure. Averaging a boolean column never worked: ActiveRecord casts the answer back to `true`, and it does so for 0% as well, so no rate could read as anything else. Janela refused that, correctly, and the refusal had nowhere to point; it now names `ratio:`. A row where the column is null is left out of the average, as SQL's `AVG` leaves it, and is not counted as a no. The number stored, ordered and compared is the fraction (`0.3119`); only the text is a percentage, to one decimal place unless you declare `precision:`. A ratio takes no `prefix:` or `suffix:` (it raises, so `31.2%%` cannot happen) and no condition form (ADR 038, #27).
17
+
18
+ - **A value and (none) can be selected together.** With a chart or table of A, B and (none), Ctrl or Cmd click on (none) beside A now shows the rows that are A or have nothing, where before selecting (none) cleared A. A plain click still replaces the selection, and Ctrl or Cmd click on (none) again takes it out. It is the union of the two in the URL you already have, `q[channel_in][]=web&q[channel_null]=1`, and the same for a `where:` or `default_where` naming both. That pair used to return no rows at all, because Ransack ANDs what it is given, so nothing that worked depended on it. Two exclusions, `not_in` with `not_null`, still mean neither (ADR 049, #69).
19
+
20
+ - **A chart pane can say how tall it is.** `janela_pane Order, :revenue, by: :placed_on, as: :line, height: 2`, or the same on a stored pane from the pane form, takes one of five steps from about 96px to about 448px, drawn as a box of that height around the chart with the chart filling it. Until now a chart was twice its width up to a cap of 20rem, and a host could lower it from outside but not set it, so a stored frame's only way to a shorter row of charts was to swap them for tables. With no height nothing changes, and a ring, a table or a single value ignores one, so switching a pane's renderer never invalidates it. The step travels in the pane URL as `?height=`. Stored panes need a migration; see `UPGRADING.md` (ADR 047, #24).
21
+
22
+ ### Changed
23
+
24
+ - `Measure#sql_alias` is now `Measure#order_by`, because a ratio has no column alias to name and is ordered by its expression. Internal, with one call site in the gem, so nothing a host declares changes; a fork that called it will need the new name (ADR 038).
25
+
26
+ ### Fixed
27
+
28
+ - **A Sprockets host on importmap-rails no longer gets a 500 from `javascript_importmap_tags`.** Janela declared its stylesheets precompilable and never its JavaScript, so on Sprockets the first page that rendered the importmap raised `AssetNotPrecompiledError: Asset janela/frame_controller.js was not declared to be precompiled in production`, in development as much as production, and no setting turns it into a warning. The engine now declares the four assets its importmap pins, for a host that uses importmap: a Sprockets host that bundles its own JavaScript is untouched, and so is a Propshaft one. If you worked around it with a manifest entry of your own, you can delete it, and nothing breaks if you leave it (ADR 004, #14).
29
+
30
+ ## [0.11.0] - 2026-09-30
31
+
32
+ ### Added
33
+
34
+ - **Clicking a bucket on a time pane filters the other panes to it.** A day on a line chart, or a row of a time table, now selects the range that bucket covers: the start of the bucket and the start of the next, written as the dimension's `_gteq` and `_lt` (`q[placed_on_gteq]=2026-09-01&q[placed_on_lt]=2026-10-01`). Until now a time pane ignored clicks, because a click wrote one condition and a bucket needs two. The pane you click keeps its whole series and marks the buckets inside the range, as every other pane does with its own selection, so a month selected elsewhere also marks the days of that month on a day pane. Clicking the selected bucket clears it; a second bucket replaces the first, and Ctrl or Cmd does not add one, since two ranges on one attribute would return no rows. A line is clicked near a point, not on the exact pixel. Range values are dates for a date column and ISO 8601 with the zone's offset for a timestamp. Ranges a host fixes with `where:` or `default_where` are unchanged (ADR 045, #18).
35
+
36
+ - `as: :doughnut` and `as: :pie`, for a part-to-whole split. `janela_pane Order, :orders, by: :status, as: :doughnut`. A ring is drawn on the server as inline SVG rather than on a canvas, so it is in the page before any JavaScript runs, prints, and has a legend of real buttons that a keyboard and a screen reader can operate; clicking a slice or a legend button filters the other panes exactly as clicking a bar does, and Ctrl or Cmd adds to the selection. A ring cannot show a negative value or a total of nothing, so a pane with either is drawn as a table and says so. Janela publishes a categorical palette to draw them in, `--janela-series-1` to `--janela-series-8` and `--janela-series-other`, in `docs/theming.md`. The engine's own pages draw a ring too, since it needs no chart runtime (ADR 046, #30).
37
+
38
+ - A categorical dimension's filter allows `not_eq` and `not_in`, alongside the existing `eq` and `in`: `Order.janela.query(:revenue, where: { status_not_eq: "refunded" })`. Until now excluding a value could only be phrased as `status_in` naming every other value, which silently stopped covering the dashboard the day a new status value was added; a frame's `default_where` (ADR 043) inherited the same gap, so "this queue never counts a refunded order" had no way to be said that stayed true as the data shape changed. A time dimension gets both for free, the same way it already inherits `not_null`. Additive; nothing that worked before is refused now (ADR 044, #64).
39
+
40
+ ### Changed
41
+
42
+ - **A range in the URL no longer narrows the time pane it names.** `q[placed_on_gteq]` and `q[placed_on_lt]` from a reader now scope every other pane and leave a time pane on that dimension showing its whole series with the range marked, where before it narrowed that pane's own series. A range a host fixes with `where:` or `default_where` still narrows it. See `UPGRADING.md` (ADR 045).
43
+ - **A bar chart's bars are no longer all one colour.** Each bar is now drawn in the palette colour for its position, so a host that has seen every bar in `--janela-accent` will see the first bar in the accent and the rest in new colours. A line chart is unchanged. To keep the old look, set `--janela-series-2` to `--janela-series-8` to your accent; see `UPGRADING.md` (ADR 046).
44
+
45
+ ### Fixed
46
+
47
+ - A chart pane no longer inherits the browser's 40px side margins on its `<figure>`. In a narrow column, such as one of four on a page, they left a bar chart a fraction of its tile's width.
48
+
8
49
  ## [0.10.0] - 2026-09-28
9
50
 
10
51
  ### Added
@@ -215,6 +256,8 @@ First alpha, installed from GitHub for testing in a single host application.
215
256
  - Only models that declare a `janela` block are addressable over HTTP.
216
257
  - ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
217
258
 
259
+ [0.12.0]: https://github.com/retail-tasker/janela/releases/tag/v0.12.0
260
+ [0.11.0]: https://github.com/retail-tasker/janela/releases/tag/v0.11.0
218
261
  [0.10.0]: https://github.com/retail-tasker/janela/releases/tag/v0.10.0
219
262
  [0.9.0]: https://github.com/retail-tasker/janela/releases/tag/v0.9.0
220
263
  [0.8.0]: https://github.com/retail-tasker/janela/releases/tag/v0.8.0
data/README.md CHANGED
@@ -27,7 +27,7 @@ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It has t
27
27
 
28
28
  ```ruby
29
29
  # Gemfile
30
- gem "janela", "~> 0.10"
30
+ gem "janela", "~> 0.12"
31
31
  ```
32
32
 
33
33
  ```ruby
@@ -55,7 +55,7 @@ The chart controller imports `chart.js`, which is a peer dependency: add `chart.
55
55
 
56
56
  **With importmap-rails:**
57
57
 
58
- Nothing to install. The engine pins `janela/frame_controller`, `janela/chart_controller` and a vendored `chart.js` for you (your own `chart.js` pin wins if you have one). Register the controllers:
58
+ Nothing to install. The engine pins `janela/frame_controller`, `janela/chart_controller` and a vendored `chart.js` for you (your own `chart.js` pin wins if you have one), and declares them precompilable, so it works the same on Propshaft and on Sprockets. Register the controllers:
59
59
 
60
60
  ```js
61
61
  // app/javascript/application.js
@@ -127,7 +127,7 @@ class Order < ApplicationRecord
127
127
  end
128
128
  ```
129
129
 
130
- A dimension with a `granularity` is a time dimension. Groupdate buckets it (`hour`, `day`, `week`, `month`, `quarter`, `year`), fills empty buckets with zero, and uses your app's `Time.zone` and week start. **On SQLite, buckets are UTC**, because SQLite cannot convert time zones: with a non-UTC `Time.zone` a daily bucket is shifted by your offset, and an early-morning row lands in the previous day. Coarser granularities blunt the shift without removing it. If you need local-day buckets on SQLite, store a local date column and use it as a plain dimension.
130
+ A dimension with a `granularity` is a time dimension. Groupdate buckets it (`hour`, `day`, `week`, `month`, `quarter`, `year`), fills empty buckets with zero, and uses your app's `Time.zone` and week start. Clicking a bucket on a time pane, a day on a line or a row of a time table, filters the other panes to the range it covers (`q[placed_on_gteq]=2026-09-01&q[placed_on_lt]=2026-09-02`). The pane clicked keeps its whole series and marks the range, and Ctrl or Cmd does not add a second one, since two ranges cannot be combined (ADR 045). **On SQLite, buckets are UTC**, because SQLite cannot convert time zones: with a non-UTC `Time.zone` a daily bucket is shifted by your offset, and an early-morning row lands in the previous day. Coarser granularities blunt the shift without removing it. If you need local-day buckets on SQLite, store a local date column and use it as a plain dimension.
131
131
 
132
132
  ### A subclass inherits
133
133
 
@@ -158,6 +158,14 @@ Precision defaults to what the schema already says. Counting rows has no decimal
158
158
 
159
159
  Formatting is rendering, never rounding. The number itself reaches a snapshot and an order clause at full precision, so a snapshot taken last month reads back under a format you declare today.
160
160
 
161
+ **The share of rows where something is true** is a ratio, and it has a measure of its own, because averaging a boolean column does not work: ActiveRecord casts the answer back to `true` (so even 0% reads as `true`), and Janela refuses it.
162
+
163
+ ```ruby
164
+ measure :expedited_rate, ratio: :expedited # 31.2%
165
+ ```
166
+
167
+ It takes a boolean column, leaves rows where the column is null out of the average (as SQL's `AVG` does) rather than counting them as no, and orders, gap fills, cross-filters and snapshots like any other measure. The number it stores is the fraction, `0.3119`, and only the text a reader sees is a percentage, to one decimal place unless you declare `precision:`. It takes no `prefix:` or `suffix:`, since it is always a percentage, and it takes no condition (`ratio: { status: "paid" }`): where a column is not already a yes or no fact, a dimension gives you the split (ADR 038).
168
+
161
169
  Then query them:
162
170
 
163
171
  ```ruby
@@ -181,7 +189,7 @@ Scope a query to whatever the current user is allowed to see with `on:`:
181
189
  Order.janela.query(:revenue, by: :status, on: policy_scope(Order))
182
190
  ```
183
191
 
184
- A filter is bound to what kind of dimension it names, not to every predicate Ransack knows (ADR 025). A categorical dimension takes `eq`, `in`, `null` and `not_null`; a time dimension additionally takes `gteq`, `gt`, `lteq` and `lt`, so a range still narrows it. Anything else, such as `_cont` or `_matches`, raises `Janela::BadRequest` naming what is allowed. A grouped query with no `limit` gets one anyway, capped at 1000, and a single filter may carry at most 1000 values.
192
+ A filter is bound to what kind of dimension it names, not to every predicate Ransack knows (ADR 025). A categorical dimension takes `eq`, `in`, `not_eq`, `not_in`, `null` and `not_null`, so it can say what it excludes as directly as what it includes (ADR 044); a time dimension additionally takes `gteq`, `gt`, `lteq` and `lt`, so a range still narrows it. Anything else, such as `_cont` or `_matches`, raises `Janela::BadRequest` naming what is allowed. A grouped query with no `limit` gets one anyway, capped at 1000, and a single filter may carry at most 1000 values.
185
193
 
186
194
  Declaring a dimension makes that attribute filterable, so Janela defines the model's Ransack allowlist for you. A model that already defines its own keeps it. A `through:` dimension also needs the **associated** model to allow the attribute, because Ransack's allowlist is per-class:
187
195
 
@@ -207,7 +215,15 @@ Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one r
207
215
  <% end %>
208
216
  ```
209
217
 
210
- A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars. `as:` is `:table` by default, `:bar` for a Chart.js bar chart, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
218
+ A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars. `as:` is `:table` by default, `:bar` for a Chart.js bar chart, `:doughnut` or `:pie` for a part-to-whole split, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
219
+
220
+ A doughnut or a pie is drawn on the server as SVG with a legend of buttons beside it, so it is in the page before any JavaScript runs and can be operated from the keyboard through the legend. Bars, doughnut slices and pie slices are drawn in a palette of eight colours by position, first to eighth, and every value after the eighth in one neutral, so a ring suits a handful of values: `limit: 8` keeps it readable. A ring cannot show a negative value, so a pane with one is drawn as a table and says so. The palette is `--janela-series-1` to `--janela-series-8` and `--janela-series-other` in [docs/theming.md](docs/theming.md) (ADR 046).
221
+
222
+ **Height.** A bar or line chart is drawn at twice its width, up to 20rem, unless the pane says how tall it is: `height: 1` to `height: 5`, from about 96px to about 448px at the default spacing (`janela_pane Order, :revenue, by: :placed_on, as: :line, height: 2`). Five steps rather than pixels, the same as `span` and `gap`, so a stored pane takes it too, from the pane form, and a host that wants an exact figure sets it against `janela-h-2` in its own CSS. With no height nothing changes. A ring, a table and a single value ignore one (ADR 047).
223
+
224
+ **Prominence.** A single value is `2rem` unless the pane says how much it matters: `prominence: 1` is a footnote at `1.25rem`, `2` is what it already is, and `3` is the hero number at `3.5rem` (`janela_pane Order, :orders, prominence: 3`). Three steps rather than a length, the same as `span` and `height`, so a stored pane takes it too, from the pane form. The label stays small at every step. With none set nothing changes, and a table, a chart and a ring ignore one (ADR 050).
225
+
226
+ **Companion columns.** A table can carry up to three more columns beside its label, each a measure or a dimension the model declares: `janela_pane Order, :expedited_rate, by: :customer, companions: [:orders, :region]` reads as customer, expedited rate, orders and region, under a header row. A measure companion is its own number, formatted as it declares, so a rate can sit beside the count behind it: 75% of four and 75% of forty are different things to act on. A dimension companion shows a value only where every row of that label's group shares one, and is blank where they differ, since grouping by it would show a label twice and picking one would show an arbitrary value as fact. Order, limit, filters and clicking a label are the primary measure's and are unchanged. Only a table draws companions, a stored snapshot shows the base table, and a time pane may carry measures but not dimensions. Each companion is one more query, which is why it is three at most (ADR 051).
211
227
 
212
228
  **Reconfiguring a pane in place**, a renderer toggle, a granularity switcher, a "show top 20" control, takes two things: name the pane with `id:`, then ask the frame to repoint it.
213
229
 
@@ -347,9 +363,9 @@ A host with no tenancy defines nothing, gets a nil owner, and is correct: nothin
347
363
 
348
364
  ### Filters and clicks
349
365
 
350
- The dashboard's filters live in the page URL as the same `q[...]` parameters, so a reload keeps them and a filtered dashboard is a link you can send: `/reports/orders?q[status_in][]=paid` renders filtered before any JavaScript runs. A pane ignores filters on its own dimension, so clicking a value re-scopes the rest of the dashboard rather than collapsing the pane you clicked. Time panes re-scope with the others but are not click sources yet; drill-down is the next decision. Every selected value is marked `aria-pressed="true"` on tables and drawn solid against faded siblings on charts, so it can be styled and read.
366
+ The dashboard's filters live in the page URL as the same `q[...]` parameters, so a reload keeps them and a filtered dashboard is a link you can send: `/reports/orders?q[status_in][]=paid` renders filtered before any JavaScript runs. A pane ignores filters on its own dimension, so clicking a value re-scopes the rest of the dashboard rather than collapsing the pane you clicked. Clicking a bucket on a time pane filters the others to the range it covers. Every selected value is marked `aria-pressed="true"` on tables and drawn solid against faded siblings on charts, so it can be styled and read.
351
367
 
352
- **Selecting more than one.** Ctrl or Cmd click adds a value to the selection and takes it out again, leaving the rest alone, which is how every list in every operating system already behaves. A plain click selects one value and replaces whatever was selected, or clears the dimension if that value was the only one. It works the same on a chart. All of it works from the keyboard too: a value is a real `<button>`, so Enter is a click and Ctrl or Cmd with Enter adds. `Escape` clears the frame's filters, and those are the only two keys Janela binds, both only while focus is inside the frame, because a single letter belongs to your application and to any text field on the page (ADR 024). A pane with no matching rows renders a `.janela-empty` paragraph. A group whose dimension is null is labelled `(none)` and filters with Ransack's null predicate rather than an empty string. Only models that declare a `janela` block can be requested over HTTP.
368
+ **Selecting more than one.** Ctrl or Cmd click adds a value to the selection and takes it out again, leaving the rest alone, which is how every list in every operating system already behaves. A plain click selects one value and replaces whatever was selected, or clears the dimension if that value was the only one. It works the same on a chart. The (none) group, the rows that have nothing there, is a member of the selection like any value: Ctrl or Cmd click it beside a value and the panes show the rows that have that value or nothing (`q[channel_in][]=web&q[channel_null]=1`), where `not_in` and `not_null` together still mean neither (ADR 049). All of it works from the keyboard too: a value is a real `<button>`, so Enter is a click and Ctrl or Cmd with Enter adds. `Escape` clears the frame's filters, and those are the only two keys Janela binds, both only while focus is inside the frame, because a single letter belongs to your application and to any text field on the page (ADR 024). A pane with no matching rows renders a `.janela-empty` paragraph. A group whose dimension is null is labelled `(none)` and filters with Ransack's null predicate rather than an empty string. Only models that declare a `janela` block can be requested over HTTP.
353
369
 
354
370
  ### Pane URLs
355
371
 
@@ -542,7 +558,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
542
558
 
543
559
  ## Status
544
560
 
545
- **v0.10.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar and line charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames found by owner and key, panes that hold words or a host partial as well as a query, a host-fixed filter no click can remove and a frame's own permanent one beside it, STI subclasses, the engine's own pages for reading and editing them and the optional vitral theme work and are covered by unit and real-browser tests, with the classes a theme may target documented in [Theming Janela](docs/theming.md). Not yet built: a visual editor, drill-down on time panes, other chart types. [Vista](docs/roadmap.md), the roadmap, says what 1.0 means and which of these are in it; open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
561
+ **v0.12.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar, line, doughnut and pie charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames found by owner and key, panes that hold words or a host partial as well as a query, a host-fixed filter no click can remove and a frame's own permanent one beside it, STI subclasses, the engine's own pages for reading and editing them and the optional vitral theme work and are covered by unit and real-browser tests, with the classes a theme may target documented in [Theming Janela](docs/theming.md). Not yet built: a visual editor, drill-down on time panes, other chart types. [Vista](docs/roadmap.md), the roadmap, says what 1.0 means and which of these are in it; open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
546
562
 
547
563
  ## Development
548
564
 
@@ -559,7 +575,13 @@ bin/rails console # console inside the dummy app, engine loaded
559
575
 
560
576
  ## Contributing
561
577
 
562
- Bug reports and pull requests are welcome on GitHub at https://github.com/retail-tasker/janela. Pull requests are reviewed on the merits of the diff, whether a person or an agent wrote them. Contributors are expected to adhere to the [code of conduct](https://github.com/retail-tasker/janela/blob/main/CODE_OF_CONDUCT.md).
578
+ Bug reports and pull requests are welcome on GitHub at https://github.com/retail-tasker/janela. Pull requests are reviewed on the merits of the diff, whether a person or an agent wrote them. [CONTRIBUTING.md](CONTRIBUTING.md) says how to run the tests, when a change needs a decision record first, and what to write down for the people who upgrade. Contributors are expected to adhere to the [code of conduct](https://github.com/retail-tasker/janela/blob/main/CODE_OF_CONDUCT.md).
579
+
580
+ ## Support and security
581
+
582
+ Janela is pre-1.0 and its public surface can still move between releases, with an upgrade note each time. Issues and pull requests are read when the maintainers can get to them; there is no support contract and no promised response time.
583
+
584
+ To report a vulnerability, use the private form described in [SECURITY.md](SECURITY.md) and not a public issue. Releases are cut by the two maintainers and published from a protected workflow; the steps are in [RELEASING.md](RELEASING.md).
563
585
 
564
586
  ## License
565
587
 
data/UPGRADING.md CHANGED
@@ -12,6 +12,79 @@ bin/rails janela:doctor
12
12
 
13
13
  It reads your application and lists what still needs changing.
14
14
 
15
+ ## 0.11.0 to 0.12.0
16
+
17
+ One migration step, if you use stored frames.
18
+
19
+ **Take the pane migrations.**
20
+
21
+ A pane can now carry a chart height (ADR 047), a single value a
22
+ prominence (ADR 050) and a table companion columns (ADR 051).
23
+ `janela_panes` gains a nullable `height`, a nullable `prominence` and a
24
+ nullable JSON `companions`:
25
+
26
+ ```bash
27
+ bin/rails janela:install:migrations
28
+ bin/rails db:migrate
29
+ ```
30
+
31
+ Every existing pane keeps all three nil and is drawn exactly as before. If you
32
+ never use stored frames, there is nothing to do.
33
+
34
+ ## 0.10.0 to 0.11.0
35
+
36
+ No migration. Two things you may see.
37
+
38
+ **A reader's range no longer narrows a time pane.**
39
+
40
+ Clicking a bucket on a time pane now writes `placed_on_gteq` and
41
+ `placed_on_lt` (ADR 045), and a time pane, like every other pane, does
42
+ not apply the reader's filters on its own dimension. It shows its whole
43
+ series and marks the buckets inside the range, so you can see where the
44
+ selection sits.
45
+
46
+ That changes one thing: a link or a form that used to send a range in
47
+ `q[...]` to narrow a time pane's own series no longer does. To fix a
48
+ range on a pane, say it in the host's own code, which is unchanged:
49
+
50
+ ```erb
51
+ <%= janela_frame @frame, where: { placed_on_gteq: 30.days.ago.to_date.to_s } %>
52
+ ```
53
+
54
+ or, on a stored frame, `default_where: { placed_on_gteq: "2026-01-01" }`
55
+ with `default_model`. Both still narrow the time pane itself. The doctor
56
+ cannot see this one, because the filter arrives at runtime.
57
+
58
+ **Bars are drawn in a palette now.**
59
+
60
+ Janela publishes a categorical palette (ADR 046), and a bar chart takes
61
+ its colours from it: the first bar in `--janela-accent`, the next seven
62
+ in `--janela-series-2` to `--janela-series-8`, and every bar after the
63
+ eighth in `--janela-series-other`. Until now every bar was the accent.
64
+ Line charts are unchanged.
65
+
66
+ If you want the old look, set the seven to your accent:
67
+
68
+ ```css
69
+ :root {
70
+ --janela-series-2: var(--janela-accent);
71
+ --janela-series-3: var(--janela-accent);
72
+ --janela-series-4: var(--janela-accent);
73
+ --janela-series-5: var(--janela-accent);
74
+ --janela-series-6: var(--janela-accent);
75
+ --janela-series-7: var(--janela-accent);
76
+ --janela-series-8: var(--janela-accent);
77
+ --janela-series-other: var(--janela-accent);
78
+ }
79
+ ```
80
+
81
+ Otherwise there is nothing to do, and the palette is yours to set to
82
+ your own brand's colours. The doctor cannot see this one: what a bar
83
+ looks like is decided in the browser.
84
+
85
+ Nothing else changed: no migration, no renamed identifier, and stored
86
+ frames and existing panes keep working.
87
+
15
88
  ## 0.9.0 to 0.10.0
16
89
 
17
90
  One migration, if you use stored frames.
@@ -9,7 +9,7 @@ Chart.register(...registerables)
9
9
  // chart is destroyed on disconnect and rebuilt on connect.
10
10
  export default class extends Controller {
11
11
  static values = { type: String, labels: Array, values: Array, filters: Object, title: String,
12
- selected: Array, formatted: Array }
12
+ selected: Array, formatted: Array, fixedHeight: Boolean }
13
13
 
14
14
  connect() {
15
15
  this.chart = new Chart(this.element, {
@@ -20,12 +20,22 @@ export default class extends Controller {
20
20
  label: this.titleValue,
21
21
  data: this.valuesValue,
22
22
  backgroundColor: this.colours(),
23
- borderColor: this.accentColour(0.9)
23
+ borderColor: this.typeValue === "bar" ? this.colours(0.9) : this.accentColour(0.9),
24
+ ...this.pointStyle()
24
25
  }]
25
26
  },
26
27
  options: {
27
28
  animation: false,
29
+ // A pane with a height is drawn into a box of that height, and the
30
+ // aspect ratio has to be off for the chart to fill it (ADR 047). It
31
+ // is decided here, when the chart is made: patched onto a chart built
32
+ // with it on, it draws at the wrong size.
33
+ maintainAspectRatio: !this.fixedHeightValue,
28
34
  scales: { y: { beginAtZero: true } },
35
+ // A line is clicked anywhere along its x position rather than on
36
+ // the exact pixel of a point, which on a dense series is a few
37
+ // pixels wide (ADR 045).
38
+ interaction: this.typeValue === "line" ? { mode: "nearest", axis: "x", intersect: false } : undefined,
29
39
  plugins: {
30
40
  legend: { display: false },
31
41
  // The server formatted every number for the table, so the tooltip
@@ -36,25 +46,59 @@ export default class extends Controller {
36
46
  onClick: (event, elements) => {
37
47
  if (elements.length === 0) return
38
48
  const label = this.labelsValue[elements[0].index]
39
- const [key, value] = this.filtersValue[String(label)] || []
49
+ const entry = this.filtersValue[String(label)]
40
50
  // A custom event carries no modifier flags of its own, so the
41
51
  // gesture is read here and passed on (ADR 024).
42
52
  const additive = event.native?.ctrlKey === true || event.native?.metaKey === true
43
- if (key) this.dispatch("toggle", { detail: { key, value, additive } })
53
+ // A category is one key and value. A time bucket is an object of
54
+ // the conditions for its range, which is never additive (ADR 045).
55
+ if (Array.isArray(entry) && entry[0]) {
56
+ this.dispatch("toggle", { detail: { key: entry[0], value: entry[1], additive } })
57
+ } else if (entry && !Array.isArray(entry)) {
58
+ this.dispatch("toggle", { detail: { filters: entry } })
59
+ }
44
60
  }
45
61
  }
46
62
  })
47
63
  }
48
64
 
49
65
  // With nothing selected every bar is solid; with a selection only the
50
- // selected ones are, and there can be more than one of them.
51
- colours() {
66
+ // selected ones are, and there can be more than one of them. A bar takes
67
+ // the colour for its position in the palette (ADR 046); a line is one
68
+ // series and keeps the accent.
69
+ colours(unselected = 0.25) {
52
70
  const selected = this.selectedValue.map(String)
53
- return this.labelsValue.map((label) =>
54
- selected.length === 0 || selected.includes(String(label))
55
- ? this.accentColour(0.9)
56
- : this.accentColour(0.25)
57
- )
71
+ return this.labelsValue.map((label, index) => {
72
+ const solid = selected.length === 0 || selected.includes(String(label))
73
+ const property = this.typeValue === "bar" ? this.seriesProperty(index) : "--janela-accent"
74
+ return this.colour(property, solid ? 0.9 : unselected)
75
+ })
76
+ }
77
+
78
+ // A line shows its selection on its points: the buckets inside the range
79
+ // are solid and larger, the rest faded. A dense series draws no points
80
+ // until one is hovered, so it stays a line (ADR 045).
81
+ pointStyle() {
82
+ if (this.typeValue !== "line") return {}
83
+
84
+ const selected = this.selectedValue.map(String)
85
+ const dense = this.labelsValue.length > 60
86
+ return {
87
+ pointBackgroundColor: this.colours(),
88
+ pointBorderColor: this.colours(),
89
+ pointRadius: this.labelsValue.map((label) => selected.includes(String(label)) ? 5 : (dense ? 0 : 3)),
90
+ pointHoverRadius: 6
91
+ }
92
+ }
93
+
94
+ // First to eighth, then the neutral. Never cycled: the ninth bar in the
95
+ // first bar's colour would be two categories drawn the same (ADR 046).
96
+ seriesProperty(index) {
97
+ return index < 8 ? `--janela-series-${index + 1}` : "--janela-series-other"
98
+ }
99
+
100
+ accentColour(alpha) {
101
+ return this.colour("--janela-accent", alpha)
58
102
  }
59
103
 
60
104
  // #62: this used to be a literal rgba(54, 162, 235, ...), Chart.js's own
@@ -62,8 +106,8 @@ export default class extends Controller {
62
106
  // every other selected thing on the page. Read off this element rather
63
107
  // than the document root, so whatever ancestor sets the property is the
64
108
  // one honoured, the way it already inherits for everything else.
65
- accentColour(alpha) {
66
- const [ r, g, b ] = this.resolvedAccent().match(/\d+/g)
109
+ colour(property, alpha) {
110
+ const [ r, g, b ] = this.resolved(property).match(/\d+/g)
67
111
  return `rgba(${r}, ${g}, ${b}, ${alpha})`
68
112
  }
69
113
 
@@ -71,16 +115,17 @@ export default class extends Controller {
71
115
  // "rgb(...)", "#7c3aed", a name, never resolved the way an ordinary
72
116
  // colour property is. Setting it as one and reading that back resolves
73
117
  // any of them the same way, rather than parsing each form by hand.
74
- resolvedAccent() {
75
- if (this.resolvedAccentValue) return this.resolvedAccentValue
118
+ resolved(property) {
119
+ this.resolvedColours ||= {}
120
+ if (this.resolvedColours[property]) return this.resolvedColours[property]
76
121
 
77
- const accent = getComputedStyle(this.element).getPropertyValue("--janela-accent").trim()
122
+ const authored = getComputedStyle(this.element).getPropertyValue(property).trim()
78
123
  const probe = document.createElement("span")
79
- probe.style.color = accent
124
+ probe.style.color = authored
80
125
  document.body.appendChild(probe)
81
- this.resolvedAccentValue = getComputedStyle(probe).color
126
+ this.resolvedColours[property] = getComputedStyle(probe).color
82
127
  probe.remove()
83
- return this.resolvedAccentValue
128
+ return this.resolvedColours[property]
84
129
  }
85
130
 
86
131
  disconnect() {
@@ -77,24 +77,52 @@ export default class extends Controller {
77
77
  // same event carries the same flags when Enter is pressed on a focused
78
78
  // value, so the keyboard needs nothing of its own (ADR 024).
79
79
  toggle(event) {
80
- const { key, value } = { ...event.detail, ...event.params }
80
+ const { key, value, filters: range } = { ...event.detail, ...event.params }
81
+ if (range) return this.toggleRange(range)
82
+
81
83
  const additive = event.ctrlKey || event.metaKey || event.detail?.additive === true
82
- const filters = { ...this.filtersValue }
83
- const selected = this.valuesFor(filters, key).includes(String(value))
84
+ const held = this.filtersValue
85
+ const filters = { ...held }
86
+ const isNone = key.endsWith("_null")
87
+ const base = key.replace(/_(in|null|eq)$/, "")
88
+ const valueKey = isNone ? `${base}_in` : key
89
+ const heldValues = this.valuesFor(held, valueKey)
90
+ const holdsNone = this.valuesFor(held, `${base}_null`).length > 0
91
+ const selected = isNone ? holdsNone : heldValues.includes(String(value))
84
92
 
85
- // The null group asks for rows that have nothing there, so it cannot be
86
- // combined with a value: Ransack ands its conditions, and the pair matches
87
- // no row at all. It is exclusive within its dimension instead.
88
93
  this.clearDimension(filters, key)
89
94
 
90
- if (key.endsWith("_null")) {
91
- if (!selected) filters[key] = "1"
95
+ // The null group is one more member of the selection, so a plain click
96
+ // replaces all of it and Ctrl or Cmd adds to it, values and null alike.
97
+ // Read together the two mean the union, which is the server's to say
98
+ // (ADR 049). Until then it was exclusive, because ANDed they match no row.
99
+ let values = additive ? heldValues : []
100
+ let none = additive ? holdsNone : false
101
+ if (isNone) {
102
+ none = !selected
92
103
  } else {
93
- let values = additive ? this.valuesFor(this.filtersValue, key) : []
94
104
  values = selected ? values.filter((each) => each !== String(value)) : [ ...values, String(value) ]
95
- if (values.length) filters[key] = [ ...new Set(values) ].sort()
96
105
  }
97
106
 
107
+ if (values.length) filters[valueKey] = [ ...new Set(values) ].sort()
108
+ if (none) filters[`${base}_null`] = "1"
109
+
110
+ this.filtersValue = filters
111
+ }
112
+
113
+ // A time bucket is two conditions, its start and the start of the next
114
+ // bucket, and they are one thing to select or clear. A range is not a set,
115
+ // so a modifier means nothing here: two ranges on one attribute are ANDed
116
+ // and return no rows, which is the failure ADR 024 measured for a value
117
+ // and the null group (ADR 045).
118
+ toggleRange(range) {
119
+ const filters = { ...this.filtersValue }
120
+ const [ first ] = Object.keys(range)
121
+ const selected = Object.entries(range).every(([ key, value ]) => String(filters[key]) === String(value))
122
+
123
+ this.clearDimension(filters, first)
124
+ if (!selected) Object.assign(filters, range)
125
+
98
126
  this.filtersValue = filters
99
127
  }
100
128
 
@@ -141,11 +169,13 @@ export default class extends Controller {
141
169
  }
142
170
 
143
171
  // Every filter Janela itself writes for the same dimension: the values, the
144
- // null group, and an _eq that a shared link may still carry. A host's own
145
- // q[...] filters use other predicates and are left alone (ADR 008).
172
+ // null group, a time range, and an _eq that a shared link may still carry.
173
+ // A host's own q[...] filters use other predicates and are left alone
174
+ // (ADR 008); a range on a time dimension is the reader's, and a click on
175
+ // that dimension replaces it.
146
176
  clearDimension(filters, key) {
147
- const base = key.replace(/_(in|null|eq)$/, "")
148
- for (const suffix of [ "in", "null", "eq" ]) delete filters[`${base}_${suffix}`]
177
+ const base = key.replace(/_(in|null|eq|gteq|gt|lteq|lt)$/, "")
178
+ for (const suffix of [ "in", "null", "eq", "gteq", "gt", "lteq", "lt" ]) delete filters[`${base}_${suffix}`]
149
179
  }
150
180
 
151
181
  // Stimulus calls this as the controller starts, with the filters the server
@@ -12,6 +12,20 @@
12
12
  --janela-space: 0.25rem;
13
13
  --janela-line: rgba(128, 128, 128, 0.3);
14
14
  --janela-accent: rgb(54, 162, 235);
15
+
16
+ /* The categorical palette (ADR 046): a bar, a doughnut slice or a pie
17
+ slice is drawn in the colour for its position, first to eighth, and
18
+ everything after the eighth in the neutral. Never cycled. The first
19
+ follows the accent, so a host's own colour still leads. */
20
+ --janela-series-1: var(--janela-accent);
21
+ --janela-series-2: #eb6834;
22
+ --janela-series-3: #1baf7a;
23
+ --janela-series-4: #eda100;
24
+ --janela-series-5: #e87ba4;
25
+ --janela-series-6: #008300;
26
+ --janela-series-7: #4a3aa7;
27
+ --janela-series-8: #e34948;
28
+ --janela-series-other: #8c8c8c;
15
29
  }
16
30
 
17
31
  .janela-frame { display: grid; }
@@ -68,6 +82,11 @@
68
82
  .janela-value { margin: 0; display: flex; flex-direction: column; gap: calc(var(--janela-space) * 1); }
69
83
  .janela-value-label { font-size: 0.85rem; opacity: 0.7; }
70
84
  .janela-value-number { font-size: 2rem; font-weight: 600; font-variant-numeric: tabular-nums; }
85
+ /* How prominent a single value is (ADR 050). Step 2 is what every value is
86
+ without one. The label stays the same small size at every step. */
87
+ .janela-prominence-1 .janela-value-number { font-size: 1.25rem; }
88
+ .janela-prominence-2 .janela-value-number { font-size: 2rem; }
89
+ .janela-prominence-3 .janela-value-number { font-size: 3.5rem; }
71
90
 
72
91
  /* Words in a stored frame (ADR 039). A heading and paragraphs, spaced by the
73
92
  same unit as everything else and otherwise left to the page. */
@@ -78,7 +97,12 @@
78
97
  table.janela-pane { width: 100%; border-collapse: collapse; }
79
98
  table.janela-pane caption { text-align: left; font-weight: 600; margin-bottom: calc(var(--janela-space) * 2); }
80
99
  table.janela-pane td { padding: calc(var(--janela-space) * 1.5) 0; border-top: 1px solid var(--janela-line); }
81
- table.janela-pane td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
100
+ table.janela-pane td:not(:first-child) { text-align: right; font-variant-numeric: tabular-nums; }
101
+ /* A companion column (ADR 051): a header row names the columns, a measure's
102
+ numbers sit right like the primary's, and a dimension's fact is a word and
103
+ sits left. */
104
+ table.janela-pane th { text-align: right; font-size: 0.85rem; font-weight: 600; opacity: 0.7; padding-bottom: calc(var(--janela-space) * 1); }
105
+ table.janela-pane th:first-child, table.janela-pane th.janela-fact, table.janela-pane td.janela-fact { text-align: left; font-variant-numeric: normal; }
82
106
  table.janela-pane button {
83
107
  font: inherit;
84
108
  color: inherit;
@@ -94,6 +118,45 @@ table.janela-pane button[aria-pressed="true"] { background: var(--janela-accent)
94
118
  .janela-chart-title { font-weight: 600; margin-bottom: calc(var(--janela-space) * 2); }
95
119
  canvas.janela-chart { width: 100% !important; max-height: 20rem; }
96
120
 
121
+ /* A pane with a height draws its chart into a box of fixed size (ADR 047), five
122
+ steps of the spacing unit so a theme that moves the unit moves them. Step 4
123
+ is the 20rem the cap allows a chart anyway. The cap stops applying inside a
124
+ box, where the box is the size. */
125
+ .janela-chart-box { position: relative; }
126
+ .janela-chart-box canvas.janela-chart { max-height: none; }
127
+ .janela-h-1 { height: calc(var(--janela-space) * 24); }
128
+ .janela-h-2 { height: calc(var(--janela-space) * 40); }
129
+ .janela-h-3 { height: calc(var(--janela-space) * 56); }
130
+ .janela-h-4 { height: calc(var(--janela-space) * 80); }
131
+ .janela-h-5 { height: calc(var(--janela-space) * 112); }
132
+
133
+ /* A chart pane is a <figure>, and a browser gives a figure 40px of margin
134
+ either side. Harmless in a wide column and most of a narrow one. */
135
+ figure.janela-pane { margin: 0; }
136
+
137
+ /* A doughnut or a pie is inline SVG with a legend beside it (ADR 046). The
138
+ gap between slices is drawn in the page's own colour so no boundary
139
+ depends on hue alone. */
140
+ .janela-ring-svg { display: block; width: 100%; max-width: 16rem; max-height: 16rem; margin: 0 auto calc(var(--janela-space) * 3); }
141
+ .janela-ring-slice { fill-rule: evenodd; stroke: Canvas; stroke-width: 0.8; }
142
+ .janela-ring-slice[data-action] { cursor: pointer; }
143
+ .janela-ring-slice.janela-dim { opacity: 0.25; }
144
+ table.janela-legend { width: 100%; border-collapse: collapse; }
145
+ table.janela-legend td { padding: calc(var(--janela-space) * 1.5) 0; border-top: 1px solid var(--janela-line); }
146
+ table.janela-legend td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
147
+ table.janela-legend button {
148
+ font: inherit;
149
+ color: inherit;
150
+ background: none;
151
+ border: 0;
152
+ padding: calc(var(--janela-space) * 0.5) calc(var(--janela-space) * 2);
153
+ border-radius: 999px;
154
+ cursor: pointer;
155
+ }
156
+ table.janela-legend button:hover { background: var(--janela-line); }
157
+ table.janela-legend button[aria-pressed="true"] { background: var(--janela-accent); color: white; }
158
+ .janela-swatch { display: inline-block; width: 0.75rem; height: 0.75rem; border-radius: 2px; vertical-align: middle; }
159
+
97
160
  /* A host whose own markup already says what a pane is puts this on any
98
161
  ancestor, and the caption, the value's label and the chart's title stop
99
162
  being drawn without leaving the accessibility tree. Hidden rather than
@@ -76,8 +76,8 @@ module Janela
76
76
  end
77
77
 
78
78
  def pane_params
79
- params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :span, :title,
80
- :heading, :body, :link, :partial ])
79
+ params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :height, :prominence, :span, :title,
80
+ :heading, :body, :link, :partial, { companions: [] } ])
81
81
  end
82
82
 
83
83
  def build_pane(choice)
@@ -8,6 +8,9 @@ module Janela
8
8
  renderer: params.fetch(:as, "table"),
9
9
  granularity: params[:granularity],
10
10
  limit: params[:limit],
11
+ height: params[:height],
12
+ prominence: params[:prominence],
13
+ companions: params[:companions],
11
14
  filters: filters,
12
15
  fixed: fixed_filters
13
16
  )
@@ -13,6 +13,9 @@ module Janela
13
13
  renderer: params.fetch(:as, "table"),
14
14
  granularity: params[:granularity],
15
15
  limit: params[:limit],
16
+ height: params[:height],
17
+ prominence: params[:prominence],
18
+ companions: params[:companions],
16
19
  snapshot: snapshot
17
20
  )
18
21