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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76a888911baddb25729442ecf07cf8975467f05792eb1881d1a6c9ec1c3d05e9
4
- data.tar.gz: e3718fafdaf889aed3fd4459b722e5ed0db5b39ecffb771cb4c5599c34c0bfb9
3
+ metadata.gz: 69f4c0147424e4d9350bcee373b832e922f54b1686acce5c80fa776432a288c6
4
+ data.tar.gz: ec2f91ec4c87dcfeb49154b54e0cda105f293e8b56580fb774750011fb9277df
5
5
  SHA512:
6
- metadata.gz: 3cb99e213c2afbf7e7a570fe4c02b34f1c07fb6d7dc70e0d0fad4753c50264a3466e6f7661c52fb545a4ffa6b50a8fb1b63a6eed5e3ecbd4dd6b7670f21ed9ec
7
- data.tar.gz: d9fbcebdd34dd825aeb98c98dc449ce92be38b50b1839e83c838730c0c5e9c3258e42d8410d818702032642496688279c0a9e1c361621d228d50dd7e92b9ea6d
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.13"
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 four calls and `janela_pane`, not one the engine serves (ADR 026, ADR 027):
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.13.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar, line, doughnut and pie charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames found by owner and key, panes that hold words or a host partial as well as a query, a host-fixed filter no click can remove and a frame's own permanent one beside it, STI subclasses, the engine's own pages for reading and editing them, tools an agent can use to read and arrange a dashboard, and the optional vitral theme work and are covered by unit and real-browser tests, with the classes a theme may target documented in [Theming Janela](docs/theming.md). Not yet built: a visual editor, and narrowing a time pane's own granularity by clicking one of its buckets (a click on a bucket does filter the others). [Vista](docs/roadmap.md), the roadmap, says what 1.0 means and which of these are in it; open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
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
- scales: { y: { beginAtZero: true } },
38
+ // Chart.js picks the ticks, so the server cannot format them; each is
39
+ // formatted here with what the measure declares (ADR 054).
40
+ scales: { y: {
41
+ beginAtZero: true,
42
+ // Room above the tallest bar, and below the lowest, for its label (ADR 054).
43
+ grace: this.valueLabelsValue ? "10%" : undefined,
44
+ ticks: { callback(value, index, ticks) { return controller.tickLabel(this, value, index, ticks) } }
45
+ } },
35
46
  // A line is clicked anywhere along its x position rather than on
36
47
  // the exact pixel of a point, which on a dense series is a few
37
48
  // pixels wide (ADR 045).
@@ -62,6 +73,46 @@ export default class extends Controller {
62
73
  })
63
74
  }
64
75
 
76
+ // The same string the tooltip shows, at the end of each bar: above a positive
77
+ // one and below a negative, where the bar is not. All of them or none: a
78
+ // label wider than its bar's slot would overlap its neighbour, and hiding only
79
+ // the ones that collide would label some bars and not others for a reason a
80
+ // reader cannot see (ADR 054).
81
+ drawValueLabels(chart) {
82
+ const bars = chart.getDatasetMeta(0).data
83
+ const values = chart.data.datasets[0].data
84
+ const { ctx } = chart
85
+ const { size, family } = Chart.defaults.font
86
+ const slot = chart.chartArea.width / bars.length
87
+
88
+ ctx.save()
89
+ ctx.font = `${size}px ${family}`
90
+ if (this.formattedValue.some((text) => ctx.measureText(text).width > slot)) return ctx.restore()
91
+
92
+ ctx.fillStyle = Chart.defaults.color
93
+ ctx.textAlign = "center"
94
+ bars.forEach((bar, index) => {
95
+ const negative = values[index] < 0
96
+ ctx.textBaseline = negative ? "top" : "bottom"
97
+ ctx.fillText(this.formattedValue[index], bar.x, negative ? bar.y + 4 : bar.y - 4)
98
+ })
99
+ ctx.restore()
100
+ }
101
+
102
+ // Chart.js's own numeric formatter, so the decimals follow the spacing
103
+ // between ticks: 20%, 40%, and 0.5%, 1% only where the ticks really are half
104
+ // a percent apart. A ratio is stored as a fraction and read as a percentage
105
+ // (ADR 038), so its ticks are scaled by 100, rounded first because 0.035 * 100
106
+ // is 3.5000000000000004. The measure's precision is not used: it says what one
107
+ // value means, and would print 20.0% at every tick.
108
+ tickLabel(scale, value, index, ticks) {
109
+ const { prefix = "", suffix = "", ratio = false } = this.tickFormatValue
110
+ const factor = ratio ? 100 : 1
111
+ const scaled = (number) => Number((number * factor).toPrecision(12))
112
+ const number = Ticks.formatters.numeric.call(scale, scaled(value), index, ticks.map((tick) => ({ ...tick, value: scaled(tick.value) })))
113
+ return `${prefix}${number}${ratio ? "%" : suffix}`
114
+ }
115
+
65
116
  // With nothing selected every bar is solid; with a selection only the
66
117
  // selected ones are, and there can be more than one of them. A bar takes
67
118
  // the colour for its position in the palette (ADR 046); a line is one
@@ -50,11 +50,11 @@ module Janela
50
50
  end
51
51
 
52
52
  def janela_not_found(error)
53
- janela_error(error, :not_found, "There is no such pane.")
53
+ janela_error(error, :not_found, I18n.t("janela.errors.not_found"))
54
54
  end
55
55
 
56
56
  def janela_bad_request(error)
57
- janela_error(error, :bad_request, "That request is not allowed on this pane.")
57
+ janela_error(error, :bad_request, I18n.t("janela.errors.bad_request"))
58
58
  end
59
59
 
60
60
  # The detail names models and filter keys, so it goes to the log; the
@@ -76,7 +76,7 @@ module Janela
76
76
  end
77
77
 
78
78
  def pane_params
79
- params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :height, :prominence, :span, :title,
79
+ params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :height, :prominence, :value_labels, :span, :title,
80
80
  :heading, :body, :link, :partial, { companions: [] } ])
81
81
  end
82
82
 
@@ -10,6 +10,7 @@ module Janela
10
10
  limit: params[:limit],
11
11
  height: params[:height],
12
12
  prominence: params[:prominence],
13
+ value_labels: params[:value_labels],
13
14
  companions: params[:companions],
14
15
  filters: filters,
15
16
  fixed: fixed_filters
@@ -15,6 +15,7 @@ module Janela
15
15
  limit: params[:limit],
16
16
  height: params[:height],
17
17
  prominence: params[:prominence],
18
+ value_labels: params[:value_labels],
18
19
  companions: params[:companions],
19
20
  snapshot: snapshot
20
21
  )
@@ -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, companions: companions.presence,
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
@@ -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
 
@@ -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
- <% query = pane.query(filters: filters, fixed: fixed, renderer: pane.chart? && !charts ? "table" : pane.renderer) %>
8
+ <%# A pane Janela will not draw is that pane's sentence and not the page's
9
+ error, as it is on the engine's own pane page (#83). %>
9
10
  <%= turbo_frame_tag pane.turbo_frame_id, class: "janela-span-#{pane.span}",
10
11
  data: { janela__frame_target: "pane", janela_src: janela_routes.frame_pane_path(pane.frame_id, pane, where: fixed.presence) } do %>
11
- <%= render "janela/queries/query", query: query, result: query.result(on: janela_scope(query.model)) %>
12
+ <%= janela_pane_body(pane, filters: filters, fixed: fixed, charts: charts) %>
12
13
  <% end %>
@@ -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>
@@ -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.
@@ -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: 054
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, and a chart has a height that suits the page. Those
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 is out, and adds tools an agent
151
- can use (ADR 053). What 1.0 waits on now is a week of real installs ([#71](https://github.com/retail-tasker/janela/issues/71)),
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
- - **Vitral's own palette** ([#67](https://github.com/retail-tasker/janela/issues/67)).
175
- The optional theme setting its own series colours instead of Janela's
176
- neutral ones.
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" },
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.13.0"
4
+ VERSION = "0.14.0"
5
5
  end
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.13.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.13.0: nothing you have to do.
232
+ Janela 0.14.0: one migration, if you use stored frames.
231
233
 
232
- New, and only if you want it: Janela::Tools gives an agent the
233
- tools to read and arrange a dashboard, scoped by your own
234
- policy_scope (ADR 053). Nothing registers itself. See docs/agents.md.
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
- Two things you may see: the doctor now warns, rather than errors,
237
- on Janela::PanesController, and a copy of
238
- janela/queries/_query.html.erb may need checking on Rails 8.2.
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