janela 0.13.0 → 0.14.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 +17 -0
- data/README.md +5 -3
- data/UPGRADING.md +22 -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 +1 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +1 -0
- data/app/helpers/janela/frames_helper.rb +23 -5
- data/app/models/janela/pane.rb +1 -1
- data/app/models/janela/query.rb +30 -2
- data/app/views/janela/frames/_pane.html.erb +3 -2
- data/app/views/janela/panes/_form.html.erb +6 -0
- data/app/views/janela/queries/_query.html.erb +3 -1
- data/config/locales/en.yml +6 -0
- data/db/migrate/20261006000001_add_value_labels_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/INDEX.md +6 -5
- data/docs/roadmap.md +16 -6
- data/lib/janela/doctor.rb +1 -1
- data/lib/janela/tools.rb +2 -1
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +15 -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: 69f4c0147424e4d9350bcee373b832e922f54b1686acce5c80fa776432a288c6
|
|
4
|
+
data.tar.gz: ec2f91ec4c87dcfeb49154b54e0cda105f293e8b56580fb774750011fb9277df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38e32300abc75aa9b738d63b2d7f638dff0d9f153e19aa32f1cb5cbd422702c4e236bc93b87e7c4474836bbd3bfddfad4f83fbaa95d523efee8e6b82b6246b12
|
|
7
|
+
data.tar.gz: fe82fff29b230ecab68eb8c14e162d214ee9c1666ead178145c73ffe776e635b8acd59fa8cc1df1011a9f091b2d50e9d9fe299e67142657ed59e69fa1d70d120
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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.14.0] - 2026-10-07
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **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).
|
|
13
|
+
|
|
14
|
+
- **`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).
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **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).
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **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).
|
|
23
|
+
|
|
8
24
|
## [0.13.0] - 2026-10-02
|
|
9
25
|
|
|
10
26
|
### Changed
|
|
@@ -273,6 +289,7 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
273
289
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
274
290
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
275
291
|
|
|
292
|
+
[0.14.0]: https://github.com/retail-tasker/janela/releases/tag/v0.14.0
|
|
276
293
|
[0.13.0]: https://github.com/retail-tasker/janela/releases/tag/v0.13.0
|
|
277
294
|
[0.12.0]: https://github.com/retail-tasker/janela/releases/tag/v0.12.0
|
|
278
295
|
[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.14"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
```ruby
|
|
@@ -232,6 +232,8 @@ 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
|
+
**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).
|
|
236
|
+
|
|
235
237
|
**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
238
|
|
|
237
239
|
**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 +398,7 @@ The model is its route key (`orders`, `sales_orders`), then the measure, then op
|
|
|
396
398
|
|
|
397
399
|
### What Janela can draw
|
|
398
400
|
|
|
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
|
|
401
|
+
`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
402
|
|
|
401
403
|
```erb
|
|
402
404
|
<% Janela.definitions.each do |definition| %>
|
|
@@ -561,7 +563,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
|
|
|
561
563
|
|
|
562
564
|
## Status
|
|
563
565
|
|
|
564
|
-
**v0.
|
|
566
|
+
**v0.14.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, 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
567
|
|
|
566
568
|
## Development
|
|
567
569
|
|
data/UPGRADING.md
CHANGED
|
@@ -12,6 +12,28 @@ bin/rails janela:doctor
|
|
|
12
12
|
|
|
13
13
|
It reads your application and lists what still needs changing.
|
|
14
14
|
|
|
15
|
+
## 0.13.0 to 0.14.0
|
|
16
|
+
|
|
17
|
+
One migration step, if you use stored frames.
|
|
18
|
+
|
|
19
|
+
**Take the pane migration.**
|
|
20
|
+
|
|
21
|
+
A bar chart pane can now draw each bar's value on the bar (ADR 054).
|
|
22
|
+
`janela_panes` gains a nullable boolean `value_labels`:
|
|
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 drawn exactly as before. If you never
|
|
30
|
+
use stored frames, there is nothing to do. `bin/rails janela:doctor` names the
|
|
31
|
+
column if a stored-frames host skips the step.
|
|
32
|
+
|
|
33
|
+
**Chart axes now read in the measure's format.** Nothing to change: a revenue
|
|
34
|
+
chart's ticks gain their `$` and a ratio chart's read as percentages. A host
|
|
35
|
+
that screenshots or asserts on a chart's tick labels will see the new text.
|
|
36
|
+
|
|
15
37
|
## 0.12.0 to 0.13.0
|
|
16
38
|
|
|
17
39
|
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, :height, :prominence, :value_labels, :span, :title,
|
|
80
80
|
:heading, :body, :link, :partial, { companions: [] } ])
|
|
81
81
|
end
|
|
82
82
|
|
|
@@ -28,13 +28,15 @@ 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, height: nil, prominence: nil, value_labels: nil, companions: nil, id: nil)
|
|
38
|
+
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, 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
|
|
|
@@ -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)
|
|
51
|
+
def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil, height: nil, prominence: nil, value_labels: nil, companions: nil)
|
|
50
52
|
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, height: height,
|
|
51
|
-
prominence: prominence, companions: companions.presence }.compact
|
|
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
56
|
turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, 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
|
@@ -84,7 +84,7 @@ module Janela
|
|
|
84
84
|
# instead (ADR 018).
|
|
85
85
|
def query(filters: {}, fixed: {}, renderer: self.renderer)
|
|
86
86
|
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,
|
|
87
|
+
renderer: renderer, granularity: granularity, limit: limit, height: height, prominence: prominence, value_labels: value_labels, companions: companions, filters: filters, fixed: fixed,
|
|
88
88
|
default: frame.default_for(definition.model), title: title)
|
|
89
89
|
end
|
|
90
90
|
|
data/app/models/janela/query.rb
CHANGED
|
@@ -33,7 +33,7 @@ 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, :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.
|
|
@@ -43,7 +43,7 @@ module Janela
|
|
|
43
43
|
parts.compact.join("_")
|
|
44
44
|
end
|
|
45
45
|
|
|
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)
|
|
46
|
+
def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
|
|
47
47
|
@definition = definition
|
|
48
48
|
@title = title
|
|
49
49
|
@measure = measure
|
|
@@ -59,6 +59,7 @@ module Janela
|
|
|
59
59
|
@limit = definition.limit!(limit) if limit.present?
|
|
60
60
|
@height = height!(height) if height.present?
|
|
61
61
|
@prominence = prominence!(prominence) if prominence.present?
|
|
62
|
+
@value_labels = value_labels!(value_labels)
|
|
62
63
|
@companions = companions!(companions)
|
|
63
64
|
end
|
|
64
65
|
|
|
@@ -73,6 +74,16 @@ module Janela
|
|
|
73
74
|
definition.measure!(measure).format(value)
|
|
74
75
|
end
|
|
75
76
|
|
|
77
|
+
# What a chart's value axis needs to read like the measure's own numbers
|
|
78
|
+
# (ADR 054). The ticks are chosen in the browser and so cannot be formatted
|
|
79
|
+
# here; this is the part of the format a tick can use. Precision is left out
|
|
80
|
+
# because a tick's decimals follow the spacing between ticks, not what one
|
|
81
|
+
# value means.
|
|
82
|
+
def tick_format
|
|
83
|
+
declared = definition.measure!(measure)
|
|
84
|
+
{ prefix: declared.prefix.to_s, suffix: declared.suffix.to_s, ratio: declared.ratio? }
|
|
85
|
+
end
|
|
86
|
+
|
|
76
87
|
def single_value?
|
|
77
88
|
dimension.nil?
|
|
78
89
|
end
|
|
@@ -110,6 +121,13 @@ module Janela
|
|
|
110
121
|
single_value? && !prominence.nil?
|
|
111
122
|
end
|
|
112
123
|
|
|
124
|
+
# Only a bar has a bar to put a value on. A line, a ring, a table and a
|
|
125
|
+
# single value ignore the flag, so a pane switched between renderers keeps
|
|
126
|
+
# it (ADR 054).
|
|
127
|
+
def labelled?
|
|
128
|
+
value_labels && chart? && renderer == "bar"
|
|
129
|
+
end
|
|
130
|
+
|
|
113
131
|
def boxed?
|
|
114
132
|
chart? && !height.nil?
|
|
115
133
|
end
|
|
@@ -388,6 +406,16 @@ module Janela
|
|
|
388
406
|
end
|
|
389
407
|
end
|
|
390
408
|
|
|
409
|
+
# A yes or a no from a URL or a form, with unset the same as no. Anything
|
|
410
|
+
# else is refused rather than read as one of them.
|
|
411
|
+
def value_labels!(value)
|
|
412
|
+
case value.to_s
|
|
413
|
+
when "", "0", "false" then false
|
|
414
|
+
when "1", "true" then true
|
|
415
|
+
else raise BadRequest, "value_labels must be 1 or 0, got #{value.inspect}"
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
391
419
|
def prominence!(value)
|
|
392
420
|
step = Integer(value.to_s, exception: false)
|
|
393
421
|
raise BadRequest, "prominence must be a whole number from #{PROMINENCES.first} to #{PROMINENCES.last}, got #{value.inspect}" unless PROMINENCES.cover?(step)
|
|
@@ -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 %>
|
|
@@ -66,6 +66,12 @@
|
|
|
66
66
|
<%= form.select :prominence, Janela::Pane::PROMINENCES.map { |step| [ t("janela.prominences.#{step}"), step ] }, include_blank: t("janela.prominences.automatic") %>
|
|
67
67
|
</div>
|
|
68
68
|
|
|
69
|
+
<div class="janela-field">
|
|
70
|
+
<%= form.check_box :value_labels %>
|
|
71
|
+
<%= form.label :value_labels %>
|
|
72
|
+
<span class="janela-hint"><%= t("janela.value_labels.hint") %></span>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
69
75
|
<div class="janela-field">
|
|
70
76
|
<%= form.label :span %>
|
|
71
77
|
<%= 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
|
@@ -22,9 +22,13 @@ en:
|
|
|
22
22
|
span: Width
|
|
23
23
|
height: Height
|
|
24
24
|
prominence: Prominence
|
|
25
|
+
value_labels: Show each bar's value
|
|
25
26
|
companions: Beside the label
|
|
26
27
|
title: Title
|
|
27
28
|
janela:
|
|
29
|
+
errors:
|
|
30
|
+
not_found: There is no such pane.
|
|
31
|
+
bad_request: That request is not allowed on this pane.
|
|
28
32
|
actions:
|
|
29
33
|
add_pane: "Add a %{name}"
|
|
30
34
|
cancel: Cancel
|
|
@@ -71,6 +75,8 @@ en:
|
|
|
71
75
|
measures: Measures
|
|
72
76
|
dimensions: Dimensions
|
|
73
77
|
hint: Up to three, drawn as columns in a table. A dimension shows a value only where every row of its group shares one.
|
|
78
|
+
value_labels:
|
|
79
|
+
hint: Draws the value at the end of each bar of a bar chart, if every one fits. Other charts ignore it.
|
|
74
80
|
prominences:
|
|
75
81
|
automatic: Automatic
|
|
76
82
|
"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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Date: 2026-09-16
|
|
3
3
|
Status: Accepted
|
|
4
4
|
Related: ADR 002, ADR 009, ADR 012, ADR 018
|
|
5
|
+
Superseded in part by: ADR 054
|
|
5
6
|
Triggers:
|
|
6
7
|
- a number rendering with more precision than it means
|
|
7
8
|
- adding an option to a pane, a frame or a pane URL
|
|
@@ -74,7 +75,9 @@ back under a format declared today.
|
|
|
74
75
|
and a chart tooltip all show the string the measure produced, and the
|
|
75
76
|
chart is handed those strings rather than formatting a second time in
|
|
76
77
|
JavaScript. The chart still plots raw numbers, because an axis is a
|
|
77
|
-
scale and not a label.
|
|
78
|
+
scale and not a label. (Superseded in part by
|
|
79
|
+
ADR 054: the axis is formatted by the measure too. The chart still plots
|
|
80
|
+
raw numbers.)
|
|
78
81
|
|
|
79
82
|
## Consequences
|
|
80
83
|
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-10-06
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 004, ADR 020, ADR 029, ADR 037, ADR 038, ADR 046, ADR 047, ADR 050, ADR 053
|
|
5
|
+
Triggers:
|
|
6
|
+
- a chart's axis showing a number the tooltip shows differently
|
|
7
|
+
- drawing text on a chart canvas, or adding a Chart.js plugin
|
|
8
|
+
- adding a boolean to a pane, a column to janela_panes, or a parameter to the pane URL
|
|
9
|
+
- a ratio, a currency or a percentage on a chart
|
|
10
|
+
- a chart too dense to label every bar
|
|
11
|
+
Topics: charts, formatting, measures, public-api, panes, urls
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 054: A Chart Reads Numbers the Way Its Measure Formats Them
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
#72 and #76, raised from a real install building a client report: a share
|
|
19
|
+
of rows by category, drawn as bars, with the percentage on each bar. Two
|
|
20
|
+
things stood in the way, and both are the same gap. A chart is the one
|
|
21
|
+
place a number reaches a reader without the measure's format applied
|
|
22
|
+
(ADR 020), except the tooltip, which a reader has to hover to see.
|
|
23
|
+
|
|
24
|
+
Both were reproduced against the demo on `main` (Chart.js 4.5.1), with the
|
|
25
|
+
chart built live and the drawn scale read from it.
|
|
26
|
+
|
|
27
|
+
**The axis is raw.** A bar chart of a ratio drew ticks `0, 0.1 ... 1.0`
|
|
28
|
+
beside tooltips reading `0.0%`. Revenue drew `0, 50,000 ... 500,000` beside
|
|
29
|
+
tooltips reading `$469,097.85`. `chart_controller.js` sets
|
|
30
|
+
`scales: { y: { beginAtZero: true } }` and formats only the tooltip.
|
|
31
|
+
|
|
32
|
+
**#72 said this was never decided. It was.** ADR 020 closes its Decision
|
|
33
|
+
with "The chart still plots raw numbers, because an axis is a scale and not
|
|
34
|
+
a label." That sentence is wrong for a reader, who reads the axis as labels
|
|
35
|
+
whatever it is to Chart.js. A ratio's axis at 0.5 against a tooltip at 50.0%
|
|
36
|
+
reads as two different numbers (ADR 038 keeps the stored value a fraction
|
|
37
|
+
for the data's sake, which is not a reason for the axis to show one). The
|
|
38
|
+
rest of ADR 020 stands: the number is never rounded or transformed, and the
|
|
39
|
+
measure is the layer that owns its format.
|
|
40
|
+
|
|
41
|
+
**Tick precision does not need a rule of Janela's own.** The worry in #72
|
|
42
|
+
was that ticks fall between data points, so the precision that suits a
|
|
43
|
+
value (`25.0%`) is wrong for a tick (`25%`). Chart.js's own numeric tick
|
|
44
|
+
formatter already derives its decimals from the spacing between ticks.
|
|
45
|
+
Calling it with a ratio's ticks scaled by 100 and appending `%` gave, on the
|
|
46
|
+
live chart:
|
|
47
|
+
|
|
48
|
+
| ratio data up to | ticks |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| 0.53 | `0%, 10% ... 60%` |
|
|
51
|
+
| 0.03 | `0%, 0.5%, 1% ... 3%` |
|
|
52
|
+
| 1 | `0% ... 100%` |
|
|
53
|
+
|
|
54
|
+
Whole percentages where the spacing is whole, half percentages only where
|
|
55
|
+
the ticks really are half a percent apart. One catch: scaling by 100 leaves
|
|
56
|
+
float noise (`3.5000000000000004%`), so the scaled value has to be rounded
|
|
57
|
+
before it is formatted.
|
|
58
|
+
|
|
59
|
+
**A bar label has to fit, and nothing guarantees it does.** Measured with a
|
|
60
|
+
label drawn at each bar's end:
|
|
61
|
+
|
|
62
|
+
- The tallest bar of a default chart touches the top of the plot area, so a
|
|
63
|
+
label above it is clipped. `scales.y.grace = "10%"` left 30px of
|
|
64
|
+
headroom in a 300px chart, and Chart.js 4.5.1 supports it.
|
|
65
|
+
- A label inside the bar was never an option: the shortest bars are about
|
|
66
|
+
2px tall.
|
|
67
|
+
- With 30 bars in a 600px chart a bar was 13px wide and a label such as
|
|
68
|
+
`$88,000.00` was 60px, so neighbouring labels overlap several deep.
|
|
69
|
+
Hiding only the ones that collide would label some bars and not others
|
|
70
|
+
for a reason a reader cannot see.
|
|
71
|
+
- A negative bar ends below its base, so a label placed above the end would
|
|
72
|
+
sit on the bar.
|
|
73
|
+
- A plugin cannot be added to a chart after it is built, so the drawing has
|
|
74
|
+
to be handed over when the controller constructs the chart, as
|
|
75
|
+
`maintainAspectRatio` is for a height (ADR 047).
|
|
76
|
+
|
|
77
|
+
### What was considered
|
|
78
|
+
|
|
79
|
+
**Formatted tick strings from the server.** The server formats a value
|
|
80
|
+
(ADR 020), but the client chooses the ticks, where they fall depends on the
|
|
81
|
+
chart's size and data, and the server cannot know them. It would have to
|
|
82
|
+
format values it is never asked about.
|
|
83
|
+
|
|
84
|
+
**A `precision` of the measure's own for ticks.** A measure's precision is
|
|
85
|
+
what one value means. A scale spanning `$0` to `$500,000` in steps of
|
|
86
|
+
50,000 does not want cents, and a ratio at one decimal place would print
|
|
87
|
+
`20.0%` at every tick. It is the answer to a different question.
|
|
88
|
+
|
|
89
|
+
**A host-supplied tick callback.** A setting, to do what one formatter
|
|
90
|
+
already does for every measure. ADR 001 prefers one obvious way, and ADR 021
|
|
91
|
+
and ADR 023 only allow a setting where a judgement is made once at boot.
|
|
92
|
+
|
|
93
|
+
**`chartjs-plugin-datalabels` for the bar labels.** A dependency, and a
|
|
94
|
+
second way to format a number on a chart. ADR 004 keeps Chart.js the one
|
|
95
|
+
thing the engine ships, and an inline plugin that draws a string the server
|
|
96
|
+
already produced is about twenty lines.
|
|
97
|
+
|
|
98
|
+
**Labels on by default.** Changes every bar chart that exists, and a chart
|
|
99
|
+
of thirty bars gains nothing from it. Unset must change nothing, as it does
|
|
100
|
+
for height and prominence.
|
|
101
|
+
|
|
102
|
+
**A setting for where the label sits, or whether it fits.** Outside the end
|
|
103
|
+
is the only place that works for every bar, including the shortest and the
|
|
104
|
+
negative, and a fit that is a judgement per chart is a decision a reader of
|
|
105
|
+
the dashboard cannot see made.
|
|
106
|
+
|
|
107
|
+
**Labels on a line, or on a ring.** A line has a point per bucket, often
|
|
108
|
+
sixty or more, and a ring already prints every value beside it (ADR 046).
|
|
109
|
+
Neither gains from it.
|
|
110
|
+
|
|
111
|
+
## Decision
|
|
112
|
+
|
|
113
|
+
**A chart's axis is formatted by the measure, always, and a bar chart can
|
|
114
|
+
be asked to draw each bar's value as a label.** The first supersedes the one
|
|
115
|
+
sentence in ADR 020 above and nothing else in it. The second is opt in.
|
|
116
|
+
|
|
117
|
+
### The axis
|
|
118
|
+
|
|
119
|
+
The server sends what a tick needs as one more data attribute on the canvas:
|
|
120
|
+
the measure's `prefix`, its `suffix`, and whether it is a ratio.
|
|
121
|
+
|
|
122
|
+
```erb
|
|
123
|
+
data-janela--chart-tick-format-value='{"prefix":"$","suffix":"","ratio":false}'
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The controller formats each tick with Chart.js's own numeric formatter and
|
|
127
|
+
adds those. A ratio's ticks are scaled by 100, rounded to remove float
|
|
128
|
+
noise, and given a `%`. The measure's `precision` is not used: the tick's
|
|
129
|
+
decimals come from the spacing between ticks, which is what answers "25%,
|
|
130
|
+
not 25.0%". It applies to a bar and a line, both of which have a value axis,
|
|
131
|
+
and it is not a setting: it is what the axis always was, now correct.
|
|
132
|
+
|
|
133
|
+
A measure with no prefix, suffix or ratio draws what it draws today.
|
|
134
|
+
|
|
135
|
+
### The labels
|
|
136
|
+
|
|
137
|
+
**A pane may carry `value_labels`, a boolean. Unset is exactly what it is
|
|
138
|
+
today.** When set on a bar chart, each bar's formatted string, the same one
|
|
139
|
+
the tooltip shows, is drawn beyond the end of the bar, above for a positive
|
|
140
|
+
value and below for a negative one. The value axis is given 10% of grace so
|
|
141
|
+
the tallest bar keeps its label in the plot.
|
|
142
|
+
|
|
143
|
+
**All or none.** The labels are drawn only if every one of them fits within
|
|
144
|
+
its category's slot, the chart area's width divided by the number of bars.
|
|
145
|
+
If any does not, none is drawn and the chart is what it was without the flag.
|
|
146
|
+
A chart never labels some bars and not others, and a host that wants the
|
|
147
|
+
labels on a dense chart makes the chart wider or the pane a bar chart of
|
|
148
|
+
fewer categories (`limit:`, ADR 007).
|
|
149
|
+
|
|
150
|
+
**One vocabulary in three places,** as height and prominence do:
|
|
151
|
+
|
|
152
|
+
```erb
|
|
153
|
+
<%= janela_pane Order, :expedited_rate, by: :status, as: :bar, value_labels: true %>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `Janela::Pane#value_labels`, a nullable boolean, offered on the pane form
|
|
157
|
+
as a checkbox.
|
|
158
|
+
- `janela_pane` and `janela_snapshot_pane` take `value_labels:`.
|
|
159
|
+
- The pane URL carries it as `?value_labels=1`, because the server draws the
|
|
160
|
+
pane again from that URL on every cross-filter.
|
|
161
|
+
|
|
162
|
+
**The name avoids `labels`.** In this codebase a chart's labels are its
|
|
163
|
+
categories: the controller's `labels` value is the x axis. `value_labels`
|
|
164
|
+
says which labels, and `values` would collide the same way with the
|
|
165
|
+
controller's `values`. It is the one name that reads the same at the helper,
|
|
166
|
+
the column, the URL and the controller (ADR 037).
|
|
167
|
+
|
|
168
|
+
**It is not part of the pane's identity.** ADR 029 identifies a frame by who
|
|
169
|
+
it is, and whether its bars carry numbers does not say which query it is. It
|
|
170
|
+
stays out of `Query.turbo_frame_id`.
|
|
171
|
+
|
|
172
|
+
**On anything that is not a bar chart it is ignored, not an error.** A
|
|
173
|
+
table, a single value, a line and a ring keep what they have, and a pane
|
|
174
|
+
switched between renderers keeps the flag, as height does on a ring
|
|
175
|
+
(ADR 047).
|
|
176
|
+
|
|
177
|
+
## Consequences
|
|
178
|
+
|
|
179
|
+
- **Every existing chart's axis can change.** A revenue chart gains a `$`
|
|
180
|
+
on its ticks and a ratio chart reads in percentages. Nothing else about it
|
|
181
|
+
moves, and a measure with no prefix, suffix or ratio is untouched. It is
|
|
182
|
+
in `CHANGELOG.md` under Changed as a visible correction, with no action for
|
|
183
|
+
a host; it is not an `UPGRADING.md` step (ADR 015), because nothing a host
|
|
184
|
+
wrote has to change. A host that overrode the tick callback on its own
|
|
185
|
+
chart is unaffected, since Janela does not set one for it.
|
|
186
|
+
- A ratio chart is readable end to end: a percentage on the axis, in the
|
|
187
|
+
tooltip, and on the bars if asked.
|
|
188
|
+
- **A migration for stored frames.** `janela_panes` gains a nullable
|
|
189
|
+
boolean `value_labels`, additive, so every existing pane is unset and
|
|
190
|
+
unchanged. A host using stored frames runs `bin/rails
|
|
191
|
+
janela:install:migrations` and `db:migrate`, as it did for `height`,
|
|
192
|
+
`prominence` and `companions`. The doctor's `unmigrated-columns` check
|
|
193
|
+
(ADR 035) is taught the new column, so a host that upgrades and skips the
|
|
194
|
+
migration is told rather than meeting an error on the pane form. A host that
|
|
195
|
+
does not use stored frames has nothing to do.
|
|
196
|
+
- **`Janela::Tools` has to learn the attribute.** Its `PANE_ATTRIBUTES` is the
|
|
197
|
+
list of a pane's own attributes an agent may set (ADR 053), and an
|
|
198
|
+
attribute not on it is refused. `value_labels` joins it in the same change as
|
|
199
|
+
the column, or an agent could arrange every other part of a pane and not
|
|
200
|
+
this one.
|
|
201
|
+
- **The pane URL grammar grows by one optional parameter.** ADR 037 counts
|
|
202
|
+
that as public surface, which is why this belongs before 1.0.
|
|
203
|
+
- **Ticks use the browser's number locale, and the tooltip uses the host's
|
|
204
|
+
delimiter** (ADR 020). A host whose `number.format.delimiter` differs from
|
|
205
|
+
its readers' browsers would see two styles on one chart. This is from how
|
|
206
|
+
Chart.js formats and is not something measured here; if it turns up in
|
|
207
|
+
practice the answer is to pass the delimiter in the same attribute, not to
|
|
208
|
+
stop using Chart.js's formatter.
|
|
209
|
+
- The all or none rule means the flag can do nothing on a dense chart, and
|
|
210
|
+
the pane form cannot say so. That is a real cost, accepted because the
|
|
211
|
+
alternative is a chart that labels bars selectively.
|
|
212
|
+
- Drawn pixels are not available to a screen reader. That is as true of the
|
|
213
|
+
bars themselves, which ADR 042 answers with the figure's caption and the
|
|
214
|
+
table beside it, and this decision does not change it.
|
|
215
|
+
- Tests read what the code produced: the drawn tick labels, and the position
|
|
216
|
+
and text of each drawn bar label, not the attribute that asked for them
|
|
217
|
+
(ADR 035).
|
|
218
|
+
- What would change this decision: a need for a label on a line or a ring,
|
|
219
|
+
which is an argument about those renderers and not about this one; or a
|
|
220
|
+
host that needs the labels to survive on a dense chart, which would be a
|
|
221
|
+
case for a step in how they are thinned and not for a placement setting.
|
data/docs/decisions/INDEX.md
CHANGED
|
@@ -26,15 +26,15 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
26
26
|
| **Dependencies** | 002, 003, 004, 006, 017, 025, 053 |
|
|
27
27
|
| **Authorisation** | 002, 003, 004, 009, 017, 019, 022, 032, 033, 034, 035, 039, 040, 048, 053 |
|
|
28
28
|
| **Performance & storage** | 007, 017, 025, 048, 051 |
|
|
29
|
-
| **Ordering & formatting** | 007, 020, 038 |
|
|
29
|
+
| **Ordering & formatting** | 007, 020, 038, 054 |
|
|
30
30
|
| **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025, 040, 043, 044, 045, 048, 049 |
|
|
31
31
|
| **Layouts & views** | 011, 012, 016, 018, 020, 027, 039, 047, 050 |
|
|
32
32
|
| **CSS & styling** | 016, 018, 023, 026, 027, 036, 042, 046, 047, 050 |
|
|
33
|
-
| **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043, 044, 047, 048, 050, 051, 053 |
|
|
33
|
+
| **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043, 044, 047, 048, 050, 051, 053, 054 |
|
|
34
34
|
| **Naming rule** | 014, 023, 036 |
|
|
35
|
-
| **JavaScript delivery & charts** | 004, 006, 026, 042, 046, 047 |
|
|
35
|
+
| **JavaScript delivery & charts** | 004, 006, 026, 042, 046, 047, 054 |
|
|
36
36
|
| **Time dimensions** | 006, 025, 045 |
|
|
37
|
-
| **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041, 045, 047, 050, 051 |
|
|
37
|
+
| **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041, 045, 047, 050, 051, 054 |
|
|
38
38
|
| **Snapshots & publishing** | 009, 020, 028, 033, 034, 051 |
|
|
39
39
|
| **AI agents & guidance** | 010, 015, 021, 053 |
|
|
40
40
|
| **The doctor & checks** | 021, 025, 032, 033, 035 |
|
|
@@ -101,7 +101,8 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
101
101
|
| 051 | A Table Can Carry Companion Columns, and a Fact Is Shown Only When It Is Shared | 2026-09-30 | Accepted |
|
|
102
102
|
| 052 | Two People Cut Releases, Reports Go Private, and Support Is Best Effort | 2026-09-30 | Accepted |
|
|
103
103
|
| 053 | An Agent Reaches Janela Through Tools the Host Scopes, and Janela Registers None | 2026-10-02 | Accepted |
|
|
104
|
+
| 054 | A Chart Reads Numbers the Way Its Measure Formats Them | 2026-10-06 | Accepted |
|
|
104
105
|
|
|
105
106
|
## Next number
|
|
106
107
|
|
|
107
|
-
Next ADR:
|
|
108
|
+
Next ADR: 055
|
data/docs/roadmap.md
CHANGED
|
@@ -133,7 +133,8 @@ change in any release, and every change of that kind carries an entry in
|
|
|
133
133
|
|
|
134
134
|
**A pane can be read.** Janela draws tables, bars, lines, doughnuts and
|
|
135
135
|
pies. A table row can carry context beside its label, a single number says
|
|
136
|
-
how prominent it is,
|
|
136
|
+
how prominent it is, a chart has a height that suits the page, its axis reads
|
|
137
|
+
in the measure's own format, and a bar can show its value. Those
|
|
137
138
|
were not extra features. They were the 5% not finished, and most of them
|
|
138
139
|
were found by people installing the gem rather than reading it.
|
|
139
140
|
|
|
@@ -147,8 +148,12 @@ working is a check nobody should rely on.
|
|
|
147
148
|
|
|
148
149
|
The near ground is clear. Every issue that was in 1.0 has shipped: chart
|
|
149
150
|
height, a ratio measure, single value prominence, companion columns, Sprockets
|
|
150
|
-
hosts, and how the project is run. 0.13.0
|
|
151
|
-
|
|
151
|
+
hosts, and how the project is run. 0.13.0 added tools an agent can use
|
|
152
|
+
(ADR 053). 0.14.0 followed: a chart's axis reads in the measure's own format,
|
|
153
|
+
so a ratio is a percentage down the side as well as in the tooltip, and a bar
|
|
154
|
+
chart can show each bar's value (ADR 054). Both came from a real install, and
|
|
155
|
+
the second adds a pane option, so it starts its own short soak. What 1.0 waits
|
|
156
|
+
on now is a week of real installs of 0.14.0 ([#71](https://github.com/retail-tasker/janela/issues/71)),
|
|
152
157
|
since much of that surface was added in the last days and has only met this
|
|
153
158
|
repository. When nothing surprising comes back, the surface is frozen and 1.0
|
|
154
159
|
is tagged. The agent tools are the one new piece of surface: the read tools are
|
|
@@ -171,9 +176,14 @@ release, and being on this list is not a refusal.
|
|
|
171
176
|
- **Telling a frame to refresh** ([#68](https://github.com/retail-tasker/janela/issues/68)).
|
|
172
177
|
One action a host's own timer or push can call, and no timer or stream of
|
|
173
178
|
Janela's own (ADR 048). Additive, so it can land after 1.0.
|
|
174
|
-
- **
|
|
175
|
-
|
|
176
|
-
|
|
179
|
+
- **A frame toolbar** ([#77](https://github.com/retail-tasker/janela/issues/77)).
|
|
180
|
+
A small strip of standard controls a host does not have to build, starting
|
|
181
|
+
with full screen. Additive, so it can land after 1.0, and it needs a decision
|
|
182
|
+
record first.
|
|
183
|
+
- **A theme panel in the demo** ([#84](https://github.com/retail-tasker/janela/issues/84)).
|
|
184
|
+
Setting how every pane is presented, starting with colours, as a page of
|
|
185
|
+
controls over the properties a theme already publishes. The demo, not the
|
|
186
|
+
gem, and any property beyond colour needs a decision record first.
|
|
177
187
|
- **A command palette for the demo** ([#41](https://github.com/retail-tasker/janela/issues/41)).
|
|
178
188
|
The demo site, not the gem.
|
|
179
189
|
- **A scroll drift on the gallery page** ([#45](https://github.com/retail-tasker/janela/issues/45)).
|
data/lib/janela/doctor.rb
CHANGED
|
@@ -148,7 +148,7 @@ module Janela
|
|
|
148
148
|
# the same thing twice.
|
|
149
149
|
ADDED_COLUMNS = {
|
|
150
150
|
"Janela::Frame" => %w[key default_model default_where],
|
|
151
|
-
"Janela::Pane" => %w[kind heading body link partial height prominence companions],
|
|
151
|
+
"Janela::Pane" => %w[kind heading body link partial height prominence companions value_labels],
|
|
152
152
|
"Janela::Snapshot" => %w[owner_type owner_id]
|
|
153
153
|
}.freeze
|
|
154
154
|
|
data/lib/janela/tools.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Janela
|
|
|
18
18
|
# The attributes of a pane a caller may set: the ones the README names. The
|
|
19
19
|
# frame, the position and the timestamps are not an agent's to write.
|
|
20
20
|
PANE_ATTRIBUTES = %w[kind model measure dimension renderer granularity limit span title height prominence
|
|
21
|
-
companions heading body link partial].freeze
|
|
21
|
+
value_labels companions heading body link partial].freeze
|
|
22
22
|
DIRECTIONS = %w[up down].freeze
|
|
23
23
|
READS = %w[describe_vocabulary list_frames get_frame read_pane].freeze
|
|
24
24
|
WRITES = %w[add_pane update_pane remove_pane move_pane].freeze
|
|
@@ -209,6 +209,7 @@ module Janela
|
|
|
209
209
|
title: { type: "string" },
|
|
210
210
|
height: { type: "integer", enum: Pane::HEIGHTS.to_a },
|
|
211
211
|
prominence: { type: "integer", enum: Pane::PROMINENCES.to_a },
|
|
212
|
+
value_labels: { type: "boolean", description: "Draw each bar's value on a bar chart; other renderers ignore it" },
|
|
212
213
|
companions: { type: "array", items: { type: "string" } },
|
|
213
214
|
heading: { type: "string" },
|
|
214
215
|
body: { type: "string" },
|
data/lib/janela/version.rb
CHANGED
data/lib/janela.rb
CHANGED
|
@@ -166,4 +166,19 @@ module Janela
|
|
|
166
166
|
def self.offered_limits
|
|
167
167
|
Pane::OFFERED_LIMITS
|
|
168
168
|
end
|
|
169
|
+
|
|
170
|
+
# The steps a pane's height and prominence take, and how many companion
|
|
171
|
+
# columns a table may carry, for a page that offers them as controls
|
|
172
|
+
# (ADR 027, ADR 047, ADR 050, ADR 051).
|
|
173
|
+
def self.heights
|
|
174
|
+
Pane::HEIGHTS.to_a
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.prominences
|
|
178
|
+
Pane::PROMINENCES.to_a
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.max_companions
|
|
182
|
+
Query::MAX_COMPANIONS
|
|
183
|
+
end
|
|
169
184
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: janela
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.14.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jay Killeen
|
|
@@ -146,6 +146,7 @@ files:
|
|
|
146
146
|
- db/migrate/20260930000001_add_height_to_janela_panes.rb
|
|
147
147
|
- db/migrate/20260930000002_add_prominence_to_janela_panes.rb
|
|
148
148
|
- db/migrate/20260930000003_add_companions_to_janela_panes.rb
|
|
149
|
+
- db/migrate/20261006000001_add_value_labels_to_janela_panes.rb
|
|
149
150
|
- docs/agents.md
|
|
150
151
|
- docs/composing.md
|
|
151
152
|
- docs/decisions/001-built-to-be-forked.md
|
|
@@ -201,6 +202,7 @@ files:
|
|
|
201
202
|
- docs/decisions/051-a-table-can-carry-companion-columns.md
|
|
202
203
|
- docs/decisions/052-how-the-project-is-run.md
|
|
203
204
|
- docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md
|
|
205
|
+
- docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md
|
|
204
206
|
- docs/decisions/INDEX.md
|
|
205
207
|
- docs/multi-tenancy.md
|
|
206
208
|
- docs/naming.md
|
|
@@ -227,15 +229,17 @@ metadata:
|
|
|
227
229
|
bug_tracker_uri: https://github.com/retail-tasker/janela/issues
|
|
228
230
|
rubygems_mfa_required: 'true'
|
|
229
231
|
post_install_message: |
|
|
230
|
-
Janela 0.
|
|
232
|
+
Janela 0.14.0: one migration, if you use stored frames.
|
|
231
233
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
234
|
+
A pane can now draw each bar's value on a bar chart (ADR 054), which
|
|
235
|
+
adds a nullable value_labels column to janela_panes. Run
|
|
236
|
+
bin/rails janela:install:migrations and then bin/rails db:migrate. If
|
|
237
|
+
you never use stored frames there is nothing to do.
|
|
235
238
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
+
Also: a chart's axis now reads in the measure's own format, so a
|
|
240
|
+
currency chart gains its prefix and a ratio reads as percentages. A
|
|
241
|
+
pane Janela will not draw is now that pane's own sentence and no
|
|
242
|
+
longer an error for the whole host page.
|
|
239
243
|
|
|
240
244
|
Steps: UPGRADING.md in this gem, or
|
|
241
245
|
https://github.com/retail-tasker/janela/blob/main/UPGRADING.md
|