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 +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +8 -4
- data/UPGRADING.md +47 -0
- data/app/assets/javascripts/janela/chart_controller.js +54 -3
- data/app/controllers/janela/application_controller.rb +2 -2
- data/app/controllers/janela/panes_controller.rb +1 -1
- data/app/controllers/janela/queries_controller.rb +2 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +2 -0
- data/app/helpers/janela/frames_helper.rb +26 -8
- data/app/models/janela/pane.rb +8 -1
- data/app/models/janela/query.rb +40 -7
- data/app/models/janela/snapshot.rb +2 -2
- data/app/views/janela/frames/_pane.html.erb +3 -2
- data/app/views/janela/panes/_form.html.erb +13 -0
- data/app/views/janela/queries/_query.html.erb +3 -1
- data/config/locales/en.yml +13 -0
- data/db/migrate/20261006000001_add_value_labels_to_janela_panes.rb +7 -0
- data/db/migrate/20261013000001_add_sort_to_janela_panes.rb +7 -0
- data/docs/decisions/020-formatting-belongs-to-the-measure.md +4 -1
- data/docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md +221 -0
- data/docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md +204 -0
- data/docs/decisions/INDEX.md +9 -7
- data/docs/roadmap.md +20 -6
- data/lib/janela/definition.rb +35 -2
- data/lib/janela/doctor.rb +1 -1
- data/lib/janela/tools.rb +6 -3
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +19 -0
- metadata +12 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b6b7c6d32876fc33b9c40e07278094bc901c548ac355d30f2d4cd2048f41cde
|
|
4
|
+
data.tar.gz: c60ff46e6023319430c63a53ee5bae675fbf98541e6639982b62c5d79cd0b76b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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, "
|
|
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, "
|
|
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,
|
|
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
|
data/app/models/janela/pane.rb
CHANGED
|
@@ -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
|
|
data/app/models/janela/query.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
<%=
|
|
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>
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|