janela 0.13.0 → 0.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76a888911baddb25729442ecf07cf8975467f05792eb1881d1a6c9ec1c3d05e9
4
- data.tar.gz: e3718fafdaf889aed3fd4459b722e5ed0db5b39ecffb771cb4c5599c34c0bfb9
3
+ metadata.gz: 9b6b7c6d32876fc33b9c40e07278094bc901c548ac355d30f2d4cd2048f41cde
4
+ data.tar.gz: c60ff46e6023319430c63a53ee5bae675fbf98541e6639982b62c5d79cd0b76b
5
5
  SHA512:
6
- metadata.gz: 3cb99e213c2afbf7e7a570fe4c02b34f1c07fb6d7dc70e0d0fad4753c50264a3466e6f7661c52fb545a4ffa6b50a8fb1b63a6eed5e3ecbd4dd6b7670f21ed9ec
7
- data.tar.gz: d9fbcebdd34dd825aeb98c98dc449ce92be38b50b1839e83c838730c0c5e9c3258e42d8410d818702032642496688279c0a9e1c361621d228d50dd7e92b9ea6d
6
+ metadata.gz: a48f8cd95951b00dfd10bf9d3a4ee9476bf486cd3660618a83f561259e61c61e22289cbce2647fe75b8463d14d25aa710354d760ad549bbf51e25d5ae67cdecb
7
+ data.tar.gz: 92f1c1a0b289f523a81b4a00d6c7c63b50d480e199d0ac42ffb0db34dc98064d4b9b44b01e0dc65d5f485fcbd4e6eb6a9d897401ad197522c66ad7541cbf85a0
data/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ 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.15.0] - 2026-10-13
9
+
10
+ ### Added
11
+
12
+ - **A category pane can be sorted by its measure either way, or by its label.** `janela_pane Order, :revenue, by: :customer, limit: 5, sort: :asc` shows the bottom five, and `janela_pane Order, :revenue, by: :cycle, as: :bar, sort: :label` draws a chart in the dimension's own order. `sort` takes `desc` (the default, and what every pane did before), `asc`, `label` and `label_desc`, as a select on the pane form and in `Janela::Tools`, and travels in the pane URL as `?sort=asc`. The limit follows the order, so `asc` with a limit is the real smallest rows and not the largest turned round, which the view could not do. A label order is the database sorting the column, so a number is in numeric order, a date in date order and a string in the database's collation. The `(none)` group is last in every order, because SQLite and PostgreSQL disagree about where NULL sorts. A time pane is always in time order and ignores it. Stored panes need a migration; see `UPGRADING.md`. `Janela.sorts` lists the four for a page that offers them as controls (ADR 055, #75, #80).
13
+
14
+ ### Changed
15
+
16
+ - **Groups tied on the measure now come out by label.** Two groups with the same value used to come out in whatever order the database produced, which with a `limit` decided which of them was shown at all. They are now in label order, ascending, in either direction. A pane with no ties draws exactly what it did. A set `sort` is part of the pane's frame id (`..._top5_asc`), and an unset one leaves every existing id as it was (ADR 055).
17
+
18
+ ## [0.14.0] - 2026-10-07
19
+
20
+ ### Added
21
+
22
+ - **A bar chart can draw each bar's value on the bar.** `janela_pane Order, :expedited_rate, by: :channel, as: :bar, value_labels: true`, or the checkbox on a stored pane's form, writes the measure's own formatted string, the one the tooltip shows, beyond the end of each bar: above a positive one and below a negative, with the value axis given a tenth of headroom so the tallest bar's label is not clipped. A chart whose labels would not all fit in their bars' share of the width draws none, rather than labelling some bars and not others. Until now a reader had to hover every bar to read a number, so a report wanting `53.0%` written on each bar could not be built. With none set nothing changes, and a line, a ring, a table and a single value ignore it. It travels in the pane URL as `?value_labels=1`, and `Janela::Tools` can set it on a pane. Stored panes need a migration; see `UPGRADING.md` (ADR 054, #76).
23
+
24
+ - **`Janela.heights`, `Janela.prominences` and `Janela.max_companions`**, for a page that offers a pane's options as controls. They answer the steps a pane's `height:` and `prominence:` take and how many companion columns a table may carry, so such a page does not read `Janela::Pane::HEIGHTS` or `Janela::Query::MAX_COMPANIONS` directly, as `Janela.renderers` already spares it for the renderers. The demo's gallery is built from them: beside each pane is a control for every option it takes, shown only where the renderer chosen is one it changes, with the declaration rewriting itself so it can be copied. No action needed (ADR 027).
25
+
26
+ ### Changed
27
+
28
+ - **A chart's value axis reads in the measure's own format.** A bar or line chart drew Chart.js's raw numbers down its side, so a ratio read `0, 0.2 ... 1` beside tooltips saying `50.0%`, and revenue read `50,000` beside `$469,097.85`. The ticks now carry the measure's `prefix` and `suffix`, and a ratio's read as percentages, whole where the spacing is whole (`20%`, `40%`) and fractional only where the ticks really are (`0.5%`, `1%`). A measure with no prefix, suffix or ratio draws what it always did, and the number plotted is unchanged. A host that has copied `chart_controller.js` will not get this and has nothing to break. No action needed (ADR 054, #72).
29
+
30
+ ### Fixed
31
+
32
+ - **One stored pane Janela will not draw no longer takes the whole host page down.** A frame rendered inline runs its panes' queries in the host's own request, with no controller of Janela's above them, so a pane that was refused raised out of the template and the host answered with a server error. That happened to a pane left on a model that no longer declares the dimension it names, as when a frame is moved between models, and to any pane when a filter named a dimension its model does not declare. The refusal is right and stays, because Ransack would otherwise drop the filter and show an unfiltered number (ADR 025); what changes is its reach. The pane shows the same plain sentence the engine's own pane page shows and the rest of the page draws. The detail, which names models and filter keys, goes to the log. A host that has not scoped is still an error, since that is a misconfiguration and not a pane that cannot be drawn (ADR 032). No action needed, and a host that deleted such panes as a workaround can stop (#83).
33
+
8
34
  ## [0.13.0] - 2026-10-02
9
35
 
10
36
  ### Changed
@@ -273,6 +299,8 @@ First alpha, installed from GitHub for testing in a single host application.
273
299
  - Only models that declare a `janela` block are addressable over HTTP.
274
300
  - ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
275
301
 
302
+ [0.15.0]: https://github.com/retail-tasker/janela/releases/tag/v0.15.0
303
+ [0.14.0]: https://github.com/retail-tasker/janela/releases/tag/v0.14.0
276
304
  [0.13.0]: https://github.com/retail-tasker/janela/releases/tag/v0.13.0
277
305
  [0.12.0]: https://github.com/retail-tasker/janela/releases/tag/v0.12.0
278
306
  [0.11.0]: https://github.com/retail-tasker/janela/releases/tag/v0.11.0
data/README.md CHANGED
@@ -27,7 +27,7 @@ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It is a
27
27
 
28
28
  ```ruby
29
29
  # Gemfile
30
- gem "janela", "~> 0.13"
30
+ gem "janela", "~> 0.15"
31
31
  ```
32
32
 
33
33
  ```ruby
@@ -224,7 +224,7 @@ Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one r
224
224
  <% end %>
225
225
  ```
226
226
 
227
- 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.
227
+ A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars, and `sort:` changes which ten (see below). `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.
228
228
 
229
229
  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).
230
230
 
@@ -232,6 +232,10 @@ A doughnut or a pie is drawn on the server as SVG with a legend of buttons besid
232
232
 
233
233
  **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).
234
234
 
235
+ **Sorting.** A breakdown by a category is largest first by default. `sort: :asc` puts the smallest first, so `limit: 5` is the bottom five, and `sort: :label` and `sort: :label_desc` order by the dimension's own value, which suits a label whose text is already the natural order, such as a period code (`janela_pane Order, :revenue, by: :cycle, as: :bar, sort: :label`). The limit follows the order. A number or a date column sorts as itself, and text sorts by your database's collation, which is not the same everywhere. The `(none)` group is always last. Groups that tie on the measure come out by label. It is a select on the pane form, `sort:` in `Janela::Tools`, and `?sort=` in the pane URL. A time series is always in time order and ignores it (ADR 055).
236
+
237
+ **Value labels.** A bar chart shows a value only in its tooltip unless the pane asks for it on the bar: `value_labels: true` (`janela_pane Order, :expedited_rate, by: :channel, as: :bar, value_labels: true`), or the checkbox on the pane form. The label is the same string the tooltip shows, the measure's own format, drawn beyond the end of the bar: above a positive one and below a negative. It is all or none: if any label is wider than its bar's share of the chart, none is drawn, rather than some bars carrying a number and others not. Make the chart wider or `limit:` the bars. With none set nothing changes, and a line, a ring, a table and a single value ignore it (ADR 054).
238
+
235
239
  **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).
236
240
 
237
241
  **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.
@@ -396,7 +400,7 @@ The model is its route key (`orders`, `sales_orders`), then the measure, then op
396
400
 
397
401
  ### What Janela can draw
398
402
 
399
- `Janela.renderers`, `Janela.granularities` and `Janela.offered_limits` answer what a pane can be drawn as, without reaching into `Janela::Query::RENDERERS`, `Janela::Dimension::GRANULARITIES` or `Janela::Pane::OFFERED_LIMITS`. `Janela.definitions` answers the other half: every model that declares a `janela` block, with its own measures and dimensions. A gallery of every renderer, live against your own data, is a page you build from those four calls and `janela_pane`, not one the engine serves (ADR 026, ADR 027):
403
+ `Janela.renderers`, `Janela.granularities` and `Janela.offered_limits` answer what a pane can be drawn as, without reaching into `Janela::Query::RENDERERS`, `Janela::Dimension::GRANULARITIES` or `Janela::Pane::OFFERED_LIMITS`. `Janela.heights`, `Janela.prominences` and `Janela.max_companions` answer the steps a pane's `height:` and `prominence:` take and how many companion columns a table may carry, for a page that offers them as controls. `Janela.definitions` answers the other half: every model that declares a `janela` block, with its own measures and dimensions. A gallery of every renderer, live against your own data, is a page you build from those calls and `janela_pane`, not one the engine serves (ADR 026, ADR 027):
400
404
 
401
405
  ```erb
402
406
  <% Janela.definitions.each do |definition| %>
@@ -561,7 +565,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
561
565
 
562
566
  ## Status
563
567
 
564
- **v0.13.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, tools an agent can use to read and arrange a dashboard, 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, and narrowing a time pane's own granularity by clicking one of its buckets (a click on a bucket does filter the others). [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).
568
+ **v0.15.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, bar charts that read in their measure's format and can show each bar's value, category panes that can be ordered either way by their measure or by their label, tools an agent can use to read and arrange a dashboard, 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, and narrowing a time pane's own granularity by clicking one of its buckets (a click on a bucket does filter the others). [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).
565
569
 
566
570
  ## Development
567
571
 
data/UPGRADING.md CHANGED
@@ -12,6 +12,53 @@ bin/rails janela:doctor
12
12
 
13
13
  It reads your application and lists what still needs changing.
14
14
 
15
+ ## 0.14.0 to 0.15.0
16
+
17
+ One migration step, if you use stored frames.
18
+
19
+ **Take the pane migration.**
20
+
21
+ A category pane can now be sorted (ADR 055). `janela_panes` gains a nullable
22
+ string `sort`:
23
+
24
+ ```bash
25
+ bin/rails janela:install:migrations
26
+ bin/rails db:migrate
27
+ ```
28
+
29
+ Every existing pane keeps it nil and is ordered exactly as before, largest
30
+ first. If you never use stored frames, there is nothing to do.
31
+ `bin/rails janela:doctor` names the column if a stored-frames host skips the
32
+ step.
33
+
34
+ **If you carry a patch that orders panes,** it can go: `sort: :asc` and
35
+ `sort: :label` are the options it was written to add. Groups that tie on the
36
+ measure now come out in label order where they used to come out in the
37
+ database's, so a pane that was relying on that has nothing to change and is
38
+ now the same every time.
39
+
40
+ ## 0.13.0 to 0.14.0
41
+
42
+ One migration step, if you use stored frames.
43
+
44
+ **Take the pane migration.**
45
+
46
+ A bar chart pane can now draw each bar's value on the bar (ADR 054).
47
+ `janela_panes` gains a nullable boolean `value_labels`:
48
+
49
+ ```bash
50
+ bin/rails janela:install:migrations
51
+ bin/rails db:migrate
52
+ ```
53
+
54
+ Every existing pane keeps it nil and is drawn exactly as before. If you never
55
+ use stored frames, there is nothing to do. `bin/rails janela:doctor` names the
56
+ column if a stored-frames host skips the step.
57
+
58
+ **Chart axes now read in the measure's format.** Nothing to change: a revenue
59
+ chart's ticks gain their `$` and a ratio chart's read as percentages. A host
60
+ that screenshots or asserts on a chart's tick labels will see the new text.
61
+
15
62
  ## 0.12.0 to 0.13.0
16
63
 
17
64
  No migration, and nothing you must change. Two things you may see.
@@ -1,5 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { Chart, registerables } from "chart.js"
2
+ import { Chart, Ticks, registerables } from "chart.js"
3
3
 
4
4
  Chart.register(...registerables)
5
5
 
@@ -9,11 +9,15 @@ 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, fixedHeight: Boolean }
12
+ selected: Array, formatted: Array, tickFormat: Object, fixedHeight: Boolean, valueLabels: Boolean }
13
13
 
14
14
  connect() {
15
+ const controller = this
15
16
  this.chart = new Chart(this.element, {
16
17
  type: this.typeValue,
18
+ // A plugin cannot be added to a chart once it is built, so it is handed
19
+ // over here, as the aspect ratio is for a height (ADR 047, ADR 054).
20
+ plugins: this.valueLabelsValue ? [ { id: "janelaValueLabels", afterDatasetsDraw: (chart) => this.drawValueLabels(chart) } ] : [],
17
21
  data: {
18
22
  labels: this.labelsValue,
19
23
  datasets: [{
@@ -31,7 +35,14 @@ export default class extends Controller {
31
35
  // is decided here, when the chart is made: patched onto a chart built
32
36
  // with it on, it draws at the wrong size.
33
37
  maintainAspectRatio: !this.fixedHeightValue,
34
- scales: { y: { beginAtZero: true } },
38
+ // Chart.js picks the ticks, so the server cannot format them; each is
39
+ // formatted here with what the measure declares (ADR 054).
40
+ scales: { y: {
41
+ beginAtZero: true,
42
+ // Room above the tallest bar, and below the lowest, for its label (ADR 054).
43
+ grace: this.valueLabelsValue ? "10%" : undefined,
44
+ ticks: { callback(value, index, ticks) { return controller.tickLabel(this, value, index, ticks) } }
45
+ } },
35
46
  // A line is clicked anywhere along its x position rather than on
36
47
  // the exact pixel of a point, which on a dense series is a few
37
48
  // pixels wide (ADR 045).
@@ -62,6 +73,46 @@ export default class extends Controller {
62
73
  })
63
74
  }
64
75
 
76
+ // The same string the tooltip shows, at the end of each bar: above a positive
77
+ // one and below a negative, where the bar is not. All of them or none: a
78
+ // label wider than its bar's slot would overlap its neighbour, and hiding only
79
+ // the ones that collide would label some bars and not others for a reason a
80
+ // reader cannot see (ADR 054).
81
+ drawValueLabels(chart) {
82
+ const bars = chart.getDatasetMeta(0).data
83
+ const values = chart.data.datasets[0].data
84
+ const { ctx } = chart
85
+ const { size, family } = Chart.defaults.font
86
+ const slot = chart.chartArea.width / bars.length
87
+
88
+ ctx.save()
89
+ ctx.font = `${size}px ${family}`
90
+ if (this.formattedValue.some((text) => ctx.measureText(text).width > slot)) return ctx.restore()
91
+
92
+ ctx.fillStyle = Chart.defaults.color
93
+ ctx.textAlign = "center"
94
+ bars.forEach((bar, index) => {
95
+ const negative = values[index] < 0
96
+ ctx.textBaseline = negative ? "top" : "bottom"
97
+ ctx.fillText(this.formattedValue[index], bar.x, negative ? bar.y + 4 : bar.y - 4)
98
+ })
99
+ ctx.restore()
100
+ }
101
+
102
+ // Chart.js's own numeric formatter, so the decimals follow the spacing
103
+ // between ticks: 20%, 40%, and 0.5%, 1% only where the ticks really are half
104
+ // a percent apart. A ratio is stored as a fraction and read as a percentage
105
+ // (ADR 038), so its ticks are scaled by 100, rounded first because 0.035 * 100
106
+ // is 3.5000000000000004. The measure's precision is not used: it says what one
107
+ // value means, and would print 20.0% at every tick.
108
+ tickLabel(scale, value, index, ticks) {
109
+ const { prefix = "", suffix = "", ratio = false } = this.tickFormatValue
110
+ const factor = ratio ? 100 : 1
111
+ const scaled = (number) => Number((number * factor).toPrecision(12))
112
+ const number = Ticks.formatters.numeric.call(scale, scaled(value), index, ticks.map((tick) => ({ ...tick, value: scaled(tick.value) })))
113
+ return `${prefix}${number}${ratio ? "%" : suffix}`
114
+ }
115
+
65
116
  // With nothing selected every bar is solid; with a selection only the
66
117
  // selected ones are, and there can be more than one of them. A bar takes
67
118
  // the colour for its position in the palette (ADR 046); a line is one
@@ -50,11 +50,11 @@ module Janela
50
50
  end
51
51
 
52
52
  def janela_not_found(error)
53
- janela_error(error, :not_found, "There is no such pane.")
53
+ janela_error(error, :not_found, I18n.t("janela.errors.not_found"))
54
54
  end
55
55
 
56
56
  def janela_bad_request(error)
57
- janela_error(error, :bad_request, "That request is not allowed on this pane.")
57
+ janela_error(error, :bad_request, I18n.t("janela.errors.bad_request"))
58
58
  end
59
59
 
60
60
  # The detail names models and filter keys, so it goes to the log; the
@@ -76,7 +76,7 @@ module Janela
76
76
  end
77
77
 
78
78
  def pane_params
79
- params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :height, :prominence, :span, :title,
79
+ params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :sort, :height, :prominence, :value_labels, :span, :title,
80
80
  :heading, :body, :link, :partial, { companions: [] } ])
81
81
  end
82
82
 
@@ -8,8 +8,10 @@ module Janela
8
8
  renderer: params.fetch(:as, "table"),
9
9
  granularity: params[:granularity],
10
10
  limit: params[:limit],
11
+ sort: params[:sort],
11
12
  height: params[:height],
12
13
  prominence: params[:prominence],
14
+ value_labels: params[:value_labels],
13
15
  companions: params[:companions],
14
16
  filters: filters,
15
17
  fixed: fixed_filters
@@ -13,8 +13,10 @@ module Janela
13
13
  renderer: params.fetch(:as, "table"),
14
14
  granularity: params[:granularity],
15
15
  limit: params[:limit],
16
+ sort: params[:sort],
16
17
  height: params[:height],
17
18
  prominence: params[:prominence],
19
+ value_labels: params[:value_labels],
18
20
  companions: params[:companions],
19
21
  snapshot: snapshot
20
22
  )
@@ -28,17 +28,19 @@ module Janela
28
28
  # rather than a different id every time the query changes (ADR 029).
29
29
  #
30
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.
31
+ # five for a chart. Both travel in the pane's URL for the same reason, and
32
+ # so does value_labels:, which puts each bar's value on a bar chart (ADR 054).
32
33
  # height: is one of five steps, and travels in the pane's URL because the
33
34
  # server draws the pane from it again on every cross-filter. It is not part
34
35
  # of the frame's id: how tall a pane is does not say which query it is
35
36
  # (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,
37
+ def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, id: nil)
38
+ query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, sort: sort, height: height, prominence: prominence,
39
+ value_labels: (1 if value_labels), companions: companions.presence,
38
40
  where: @janela_fixed_filters.presence }.compact
39
41
  base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
40
42
 
41
- turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
43
+ turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, sort: sort),
42
44
  src: janela_with_page_filters(base),
43
45
  loading: :lazy,
44
46
  data: { janela__frame_target: "pane", janela_src: base }
@@ -46,15 +48,31 @@ module Janela
46
48
 
47
49
  # A pane as it was when the snapshot was taken: same shape as janela_pane,
48
50
  # not part of the live frame's filter state (ADR 009).
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
51
+ def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil)
52
+ query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, sort: sort, height: height,
53
+ prominence: prominence, value_labels: (1 if value_labels), companions: companions.presence }.compact
52
54
  src = janela_routes.snapshot_pane_path(snapshot, model.model_name.route_key, measure, by, **query)
53
55
 
54
- turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, snapshot: snapshot),
56
+ turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, sort: sort, snapshot: snapshot),
55
57
  src: src, loading: :lazy
56
58
  end
57
59
 
60
+ # What a stored pane draws, inline in the host's own request. The engine's
61
+ # pane page rescues a refused request in its controller; there is none above
62
+ # this, so a refusal would be a server error for the whole host page. A
63
+ # filter a model does not declare is still refused (ADR 025), only now by
64
+ # the one pane that cannot honour it. The detail names models and filter
65
+ # keys, so it goes to the log and the reader sees the plain sentence. A
66
+ # missing scope is deliberately not rescued: that is a host to be told, not
67
+ # a pane that cannot be drawn (ADR 032, #83).
68
+ def janela_pane_body(pane, filters:, fixed:, charts:)
69
+ query = pane.query(filters: filters, fixed: fixed, renderer: pane.chart? && !charts ? "table" : pane.renderer)
70
+ render "janela/queries/query", query: query, result: query.result(on: janela_scope(query.model))
71
+ rescue Janela::BadRequest, Janela::NotFound => error
72
+ Rails.logger.warn("Janela: pane #{pane.id}: #{error.message}")
73
+ tag.p(t(error.is_a?(Janela::NotFound) ? "janela.errors.not_found" : "janela.errors.bad_request"), class: "janela-pane janela-error")
74
+ end
75
+
58
76
  private
59
77
  # Wired once per frame, so a host asks a pane to go to a different query
60
78
  # by dispatching an event from anywhere inside rather than by reaching
@@ -14,6 +14,10 @@ module Janela
14
14
  # How prominent a single value is drawn (ADR 050).
15
15
  PROMINENCES = Query::PROMINENCES
16
16
  LIMITS = (1..1000).freeze
17
+
18
+ # How a category pane is ordered (ADR 055). Nil is the default, largest
19
+ # first by the measure.
20
+ SORTS = Definition::SORTS
17
21
  # A form cannot offer a thousand options, and these are the row counts a
18
22
  # dashboard actually asks for. Any limit inside LIMITS is still valid.
19
23
  OFFERED_LIMITS = [ 5, 10, 20, 50, 100 ].freeze
@@ -38,8 +42,11 @@ module Janela
38
42
  validates :kind, inclusion: { in: KINDS }
39
43
  validates :span, inclusion: { in: SPANS }
40
44
  validates :limit, inclusion: { in: LIMITS }, allow_nil: true
45
+ validates :sort, inclusion: { in: SORTS }, allow_nil: true
41
46
  validates :height, inclusion: { in: HEIGHTS }, allow_nil: true
42
47
  validates :prominence, inclusion: { in: PROMINENCES }, allow_nil: true
48
+ # A form sends an empty string for "Largest first", which is unset.
49
+ normalizes :sort, with: ->(value) { value.presence }
43
50
  before_validation :normalise_companions
44
51
  validate :companions_are_declared
45
52
  validates :measure, presence: true, if: :query?
@@ -84,7 +91,7 @@ module Janela
84
91
  # instead (ADR 018).
85
92
  def query(filters: {}, fixed: {}, renderer: self.renderer)
86
93
  Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
87
- renderer: renderer, granularity: granularity, limit: limit, height: height, prominence: prominence, companions: companions, filters: filters, fixed: fixed,
94
+ renderer: renderer, granularity: granularity, limit: limit, sort: sort, height: height, prominence: prominence, value_labels: value_labels, companions: companions, filters: filters, fixed: fixed,
88
95
  default: frame.default_for(definition.model), title: title)
89
96
  end
90
97
 
@@ -33,17 +33,18 @@ module Janela
33
33
  # dimension's shared fact, by label.
34
34
  Companion = Struct.new(:name, :header, :fact, :cells, keyword_init: true)
35
35
 
36
- attr_reader :definition, :measure, :dimension, :renderer, :limit, :height, :prominence, :companions, :filters, :fixed, :default, :snapshot
36
+ attr_reader :definition, :measure, :dimension, :renderer, :limit, :sort, :height, :prominence, :value_labels, :companions, :filters, :fixed, :default, :snapshot
37
37
 
38
38
  # The helper renders the turbo frame and the controller renders its
39
39
  # replacement, so both derive the id the same way from the same parameters.
40
- def self.turbo_frame_id(model:, measure:, by: nil, as: :table, granularity: nil, limit: nil, snapshot: nil)
40
+ def self.turbo_frame_id(model:, measure:, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, snapshot: nil)
41
41
  parts = [ "janela", ("snapshot_#{snapshot.id}" if snapshot), model.model_name.route_key, measure, by,
42
- (granularity if by), (as unless by.nil?), ("top#{limit}" if by && limit) ]
42
+ (granularity if by), (as unless by.nil?), ("top#{limit}" if by && limit),
43
+ (sort if by && sort.present? && sort.to_s != "desc") ]
43
44
  parts.compact.join("_")
44
45
  end
45
46
 
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)
47
+ def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
47
48
  @definition = definition
48
49
  @title = title
49
50
  @measure = measure
@@ -57,8 +58,13 @@ module Janela
57
58
  raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
58
59
  @granularity = Dimension.granularity!(granularity) if granularity.present?
59
60
  @limit = definition.limit!(limit) if limit.present?
61
+ # Unset and desc are the same pane, and stay the same id and the same
62
+ # snapshot key (ADR 055).
63
+ chosen = definition.sort!(sort)
64
+ @sort = chosen unless chosen == "desc"
60
65
  @height = height!(height) if height.present?
61
66
  @prominence = prominence!(prominence) if prominence.present?
67
+ @value_labels = value_labels!(value_labels)
62
68
  @companions = companions!(companions)
63
69
  end
64
70
 
@@ -73,6 +79,16 @@ module Janela
73
79
  definition.measure!(measure).format(value)
74
80
  end
75
81
 
82
+ # What a chart's value axis needs to read like the measure's own numbers
83
+ # (ADR 054). The ticks are chosen in the browser and so cannot be formatted
84
+ # here; this is the part of the format a tick can use. Precision is left out
85
+ # because a tick's decimals follow the spacing between ticks, not what one
86
+ # value means.
87
+ def tick_format
88
+ declared = definition.measure!(measure)
89
+ { prefix: declared.prefix.to_s, suffix: declared.suffix.to_s, ratio: declared.ratio? }
90
+ end
91
+
76
92
  def single_value?
77
93
  dimension.nil?
78
94
  end
@@ -110,6 +126,13 @@ module Janela
110
126
  single_value? && !prominence.nil?
111
127
  end
112
128
 
129
+ # Only a bar has a bar to put a value on. A line, a ring, a table and a
130
+ # single value ignore the flag, so a pane switched between renderers keeps
131
+ # it (ADR 054).
132
+ def labelled?
133
+ value_labels && chart? && renderer == "bar"
134
+ end
135
+
113
136
  def boxed?
114
137
  chart? && !height.nil?
115
138
  end
@@ -188,7 +211,7 @@ module Janela
188
211
 
189
212
  def turbo_frame_id
190
213
  self.class.turbo_frame_id(model: model, measure: measure, by: dimension, as: renderer,
191
- granularity: @granularity, limit: limit, snapshot: snapshot)
214
+ granularity: @granularity, limit: limit, sort: sort, snapshot: snapshot)
192
215
  end
193
216
 
194
217
  # A pane row may carry its own title, which is the first analyst authored
@@ -208,7 +231,7 @@ module Janela
208
231
  # What identifies this pane's data inside a snapshot.
209
232
  def lookup_key
210
233
  { "model" => model.model_name.route_key, "measure" => measure.to_s, "dimension" => dimension&.to_s,
211
- "granularity" => granularity&.to_s, "limit" => limit }
234
+ "granularity" => granularity&.to_s, "limit" => limit }.tap { |key| key["sort"] = sort if sort }
212
235
  end
213
236
 
214
237
  def result(on: nil)
@@ -220,7 +243,7 @@ module Janela
220
243
  # selection, which this pane shows the alternatives to (ADR 040, 043).
221
244
  on = definition.narrow(on || model.all, default) if default.present?
222
245
  on = definition.narrow(on || model.all, fixed) if fixed.present?
223
- primary = time? ? time_result(on) : definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
246
+ primary = time? ? time_result(on) : definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit, sort: sort)
224
247
  @companion_columns = companions? ? fetch_companions(primary, on) : []
225
248
  primary
226
249
  end
@@ -388,6 +411,16 @@ module Janela
388
411
  end
389
412
  end
390
413
 
414
+ # A yes or a no from a URL or a form, with unset the same as no. Anything
415
+ # else is refused rather than read as one of them.
416
+ def value_labels!(value)
417
+ case value.to_s
418
+ when "", "0", "false" then false
419
+ when "1", "true" then true
420
+ else raise BadRequest, "value_labels must be 1 or 0, got #{value.inspect}"
421
+ end
422
+ end
423
+
391
424
  def prominence!(value)
392
425
  step = Integer(value.to_s, exception: false)
393
426
  raise BadRequest, "prominence must be a whole number from #{PROMINENCES.first} to #{PROMINENCES.last}, got #{value.inspect}" unless PROMINENCES.cover?(step)
@@ -40,9 +40,9 @@ module Janela
40
40
  @panes = []
41
41
  end
42
42
 
43
- def pane(model, measure, by: nil, granularity: nil, limit: nil, on: nil)
43
+ def pane(model, measure, by: nil, granularity: nil, limit: nil, sort: nil, on: nil)
44
44
  query = Query.new(definition: model.janela, measure: measure, dimension: by,
45
- granularity: granularity, limit: limit, filters: filters)
45
+ granularity: granularity, limit: limit, sort: sort, filters: filters)
46
46
  panes << query.lookup_key.merge("result" => plain(query.result(on: on)))
47
47
  end
48
48
 
@@ -5,8 +5,9 @@
5
5
  change, which is what makes cross-filtering work from here. %>
6
6
  <%# A table needs no JavaScript, so it is what a surface with no chart runtime
7
7
  shows in place of an empty canvas (ADR 018). %>
8
- <% query = pane.query(filters: filters, fixed: fixed, renderer: pane.chart? && !charts ? "table" : pane.renderer) %>
8
+ <%# A pane Janela will not draw is that pane's sentence and not the page's
9
+ error, as it is on the engine's own pane page (#83). %>
9
10
  <%= turbo_frame_tag pane.turbo_frame_id, class: "janela-span-#{pane.span}",
10
11
  data: { janela__frame_target: "pane", janela_src: janela_routes.frame_pane_path(pane.frame_id, pane, where: fixed.presence) } do %>
11
- <%= render "janela/queries/query", query: query, result: query.result(on: janela_scope(query.model)) %>
12
+ <%= janela_pane_body(pane, filters: filters, fixed: fixed, charts: charts) %>
12
13
  <% end %>
@@ -41,6 +41,13 @@
41
41
  <%= form.select :limit, Janela::Pane::OFFERED_LIMITS, include_blank: t("janela.panes.no_limit") %>
42
42
  </div>
43
43
 
44
+ <%# Blank is largest first, so desc is not offered a second time. %>
45
+ <div class="janela-field">
46
+ <%= form.label :sort %>
47
+ <%= form.select :sort, (Janela.sorts - %w[desc]).map { |value| [ t("janela.sorts.#{value}"), value ] }, include_blank: t("janela.sorts.desc") %>
48
+ <span class="janela-hint"><%= t("janela.sorts.hint") %></span>
49
+ </div>
50
+
44
51
  <%# What may sit beside the label: this model's other measures and its
45
52
  categorical dimensions. The pane's own choices are left out when they are
46
53
  already made (ADR 051). %>
@@ -66,6 +73,12 @@
66
73
  <%= form.select :prominence, Janela::Pane::PROMINENCES.map { |step| [ t("janela.prominences.#{step}"), step ] }, include_blank: t("janela.prominences.automatic") %>
67
74
  </div>
68
75
 
76
+ <div class="janela-field">
77
+ <%= form.check_box :value_labels %>
78
+ <%= form.label :value_labels %>
79
+ <span class="janela-hint"><%= t("janela.value_labels.hint") %></span>
80
+ </div>
81
+
69
82
  <div class="janela-field">
70
83
  <%= form.label :span %>
71
84
  <%= form.select :span, Janela::Pane::SPANS.to_a %>
@@ -27,8 +27,10 @@
27
27
  "janela--chart-labels-value": result.keys.to_json,
28
28
  "janela--chart-values-value": result.values.map(&:to_f).to_json,
29
29
  "janela--chart-formatted-value": result.values.map { |measured| query.format(measured) }.to_json,
30
+ "janela--chart-tick-format-value": query.tick_format.to_json,
30
31
  "janela--chart-filters-value": query.filters_for(result.keys).to_json,
31
- "janela--chart-fixed-height-value": (true if query.boxed?)
32
+ "janela--chart-fixed-height-value": (true if query.boxed?),
33
+ "janela--chart-value-labels-value": (true if query.labelled?)
32
34
  }) %>
33
35
  <%= query.boxed? ? tag.div(canvas, class: "janela-chart-box janela-h-#{query.height}") : canvas %>
34
36
  </figure>
@@ -19,12 +19,17 @@ en:
19
19
  renderer: Renderer
20
20
  granularity: Granularity
21
21
  limit: Rows
22
+ sort: Order
22
23
  span: Width
23
24
  height: Height
24
25
  prominence: Prominence
26
+ value_labels: Show each bar's value
25
27
  companions: Beside the label
26
28
  title: Title
27
29
  janela:
30
+ errors:
31
+ not_found: There is no such pane.
32
+ bad_request: That request is not allowed on this pane.
28
33
  actions:
29
34
  add_pane: "Add a %{name}"
30
35
  cancel: Cancel
@@ -71,6 +76,14 @@ en:
71
76
  measures: Measures
72
77
  dimensions: Dimensions
73
78
  hint: Up to three, drawn as columns in a table. A dimension shows a value only where every row of its group shares one.
79
+ value_labels:
80
+ hint: Draws the value at the end of each bar of a bar chart, if every one fits. Other charts ignore it.
81
+ sorts:
82
+ desc: Largest first
83
+ asc: Smallest first
84
+ label: By label, A to Z
85
+ label_desc: By label, Z to A
86
+ hint: Applies to a breakdown by a category. A time series is always in time order.
74
87
  prominences:
75
88
  automatic: Automatic
76
89
  "1": Footnote
@@ -0,0 +1,7 @@
1
+ class AddValueLabelsToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # Whether a bar chart draws each bar's value on the bar (ADR 054). Null is
4
+ # what every existing pane is: unset, and drawn exactly as before.
5
+ add_column :janela_panes, :value_labels, :boolean
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ class AddSortToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # How a category pane is ordered (ADR 055): desc, asc, label or label_desc.
4
+ # Null is what every existing pane is: unset, and ordered exactly as before.
5
+ add_column :janela_panes, :sort, :string
6
+ end
7
+ end