janela 0.12.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 +36 -1
- data/README.md +31 -26
- data/UPGRADING.md +60 -1
- data/app/assets/javascripts/janela/chart_controller.js +54 -3
- data/app/assets/stylesheets/vitral.css +21 -0
- 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 +17 -15
- data/config/locales/en.yml +6 -0
- data/db/migrate/20261006000001_add_value_labels_to_janela_panes.rb +7 -0
- data/docs/agents.md +97 -0
- data/docs/composing.md +14 -14
- data/docs/decisions/020-formatting-belongs-to-the-measure.md +4 -1
- data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +187 -0
- data/docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md +221 -0
- data/docs/decisions/INDEX.md +11 -9
- data/docs/multi-tenancy.md +22 -4
- data/docs/roadmap.md +37 -13
- data/docs/theming.md +6 -4
- data/lib/janela/doctor.rb +66 -8
- data/lib/janela/tools.rb +221 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +16 -0
- metadata +16 -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,39 @@ 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
|
+
|
|
24
|
+
## [0.13.0] - 2026-10-02
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **The vitral theme draws charts in its own colours.** Bars, doughnut slices and pie slices were the stained glass accent for the first and Janela's neutral set for the other seven; vitral now sets all eight, as soft glass tones: a muted blue, sage, lavender, plum, clay, teal, ochre and dusty rose, with the grey of lead came for anything past the eighth. A first version in saturated jewel tones was rejected by eye as a rainbow against the pale tiles, so these sit at about half the chroma and lean on differences in lightness. They were chosen as an ordered set and checked together, with adjacent colour-vision separation of 12.4 (the target is 8) and adjacent normal-vision separation of 15.8 (the floor is 15). Slot 1 is a softer blue of vitral's own; `--janela-accent` stays the stronger blue for what is selected. A host that uses vitral and has set its own `--janela-series-N` keeps them. No action needed (ADR 026, #67).
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **The engine's own chart partial compiles through Herb.** Rails 8.2 renders HTML templates through Herb, which refuses ERB that writes an attribute's name or sits in attribute position, and `janela/queries/_query.html.erb` did both for the chart's canvas. It is now one `tag.canvas` call that renders the same attributes, so a host on the 8.2 defaults is not broken by Janela's own template. A host that has copied that partial keeps its copy, which Herb will reject if it still has ERB in an attribute position: `bin/rails app:herb:check` on Rails 8.2 lists any. No action needed otherwise.
|
|
33
|
+
- **The doctor no longer calls `Janela::PanesController` a stale name.** It reported an error that the name "is now `Janela::QueriesController`", true from 0.7 until 0.12 added a `Janela::PanesController` back as the stored-pane form's controller, which owns `pane_params`. A host patching that form was told to move the patch to a controller with no `pane_params`, which would have silently stopped its extra pane param saving, and the error failed `janela:doctor` until the check was silenced. It is now a warning that says what the name means today. The check is still silenced by `stale-identifiers`, and `Janela::SnapshotPanesController` is unchanged (#74).
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **Tools for an agent: `Janela::Tools`.** An agent in a host had no way to read or arrange a dashboard except by writing `Janela::Frame` and `Janela::Pane` rows from a console. `Janela::Tools.new(scope:)` is plain Ruby with a name, a description and a JSON schema for each of `describe_vocabulary`, `list_frames`, `get_frame` and `read_pane`, and with `write: true`, `add_pane`, `update_pane`, `remove_pane` and `move_pane`. `scope:` is required and takes a model class and returns a relation, the answer `policy_scope` gives, so a read or a change reaches only what the caller may see; leaving it out raises `Janela::Unscoped` when the tools are built. Janela registers nothing, serves nothing and depends on no MCP library: [docs/agents.md](docs/agents.md) shows the adapter for the official Ruby SDK, which Janela's own tests run. No action needed (ADR 053, #73).
|
|
38
|
+
|
|
39
|
+
- **The doctor notices a skipped migration, not only a missing table.** `bin/rails janela:doctor` reported a missing `janela_panes` and said nothing when the table was there but a release had added a column to it, so a host that upgraded and did not run `db:migrate` was told all was well, and met an error the first time a pane form or a stored pane read `height`, `prominence` or `companions`. A new `unmigrated-columns` check lists the missing columns by table and names the two commands. It can be silenced by that name like any other (ADR 021, ADR 035).
|
|
40
|
+
|
|
8
41
|
## [0.12.0] - 2026-10-01
|
|
9
42
|
|
|
10
43
|
### Added
|
|
@@ -40,7 +73,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
40
73
|
### Changed
|
|
41
74
|
|
|
42
75
|
- **A range in the URL no longer narrows the time pane it names.** `q[placed_on_gteq]` and `q[placed_on_lt]` from a reader now scope every other pane and leave a time pane on that dimension showing its whole series with the range marked, where before it narrowed that pane's own series. A range a host fixes with `where:` or `default_where` still narrows it. See `UPGRADING.md` (ADR 045).
|
|
43
|
-
- **A bar chart's bars are no longer all one colour.** Each bar is now drawn in the palette colour for its position, so a host that has seen every bar in `--janela-accent` will see the first bar in the accent and the rest in new colours. A line chart is unchanged. To keep the old look, set `--janela-series-2` to `--janela-series-8
|
|
76
|
+
- **A bar chart's bars are no longer all one colour.** Each bar is now drawn in the palette colour for its position, so a host that has seen every bar in `--janela-accent` will see the first bar in the accent and the rest in new colours. A line chart is unchanged. To keep the old look, set `--janela-series-2` to `--janela-series-8`, and `--janela-series-other`, to your accent; see `UPGRADING.md` (ADR 046).
|
|
44
77
|
|
|
45
78
|
### Fixed
|
|
46
79
|
|
|
@@ -256,6 +289,8 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
256
289
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
257
290
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
258
291
|
|
|
292
|
+
[0.14.0]: https://github.com/retail-tasker/janela/releases/tag/v0.14.0
|
|
293
|
+
[0.13.0]: https://github.com/retail-tasker/janela/releases/tag/v0.13.0
|
|
259
294
|
[0.12.0]: https://github.com/retail-tasker/janela/releases/tag/v0.12.0
|
|
260
295
|
[0.11.0]: https://github.com/retail-tasker/janela/releases/tag/v0.11.0
|
|
261
296
|
[0.10.0]: https://github.com/retail-tasker/janela/releases/tag/v0.10.0
|
data/README.md
CHANGED
|
@@ -6,7 +6,7 @@ PowerBI-style dashboards and cross-filtering slicers, native to Rails and Active
|
|
|
6
6
|
|
|
7
7
|
## First principle
|
|
8
8
|
|
|
9
|
-
Janela is a PowerBI-style library built on Ruby and Stimulus, meant to drop onto any Ruby on Rails application. No JS framework, no build step of its own, no separate frontend app. Just a gem you add to an existing Rails app's Gemfile and two Stimulus controllers that ship with it.
|
|
9
|
+
Janela is a PowerBI-style library built on Ruby and Stimulus, meant to drop onto any Ruby on Rails application. No JS framework, no build step of its own, no separate frontend app. Just a gem you add to an existing Rails app's Gemfile and two Stimulus controllers that ship with it, plus an optional third for the theme.
|
|
10
10
|
|
|
11
11
|
## Why
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ Every Rails BI option today is one of:
|
|
|
14
14
|
|
|
15
15
|
- **SQL-first** (Blazer). Powerful, but the query is a black box to your models and associations.
|
|
16
16
|
- **Admin-panel-first** (RailsAdmin, ActiveAdmin, Motor Admin, Avo). Association-aware filtering, but built for CRUD, not for composing multiple charts that filter each other.
|
|
17
|
-
- **A dead end for cross-filtering**. None of the above
|
|
17
|
+
- **A dead end for cross-filtering**. None of the above are built around clicking one chart to re-scope every other chart on the page. That's the PowerBI/Tableau slicer experience, and it's what Janela is for.
|
|
18
18
|
|
|
19
19
|
Janela's bet: the same dashboard definition should serve two audiences without being two systems.
|
|
20
20
|
|
|
@@ -23,11 +23,11 @@ Janela's bet: the same dashboard definition should serve two audiences without b
|
|
|
23
23
|
|
|
24
24
|
## Installation
|
|
25
25
|
|
|
26
|
-
Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It
|
|
26
|
+
Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It is a gem, plus a JavaScript package installed from GitHub if your app bundles its JavaScript. An app on importmap-rails needs only the gem. It needs Rails 8.0+ and Ruby 3.3+ (see Requirements below).
|
|
27
27
|
|
|
28
28
|
```ruby
|
|
29
29
|
# Gemfile
|
|
30
|
-
gem "janela", "~> 0.
|
|
30
|
+
gem "janela", "~> 0.14"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
```ruby
|
|
@@ -35,6 +35,15 @@ gem "janela", "~> 0.12"
|
|
|
35
35
|
mount Janela::Engine => "/dashboards" # or /reports, or wherever you like
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
Janela reads nothing until your `ApplicationController` says what a visitor may see, so the first visit to `/dashboards` raises `Janela::Unscoped` without it. An app with nothing to hide answers once:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# app/controllers/application_controller.rb
|
|
42
|
+
private def policy_scope(model) = model.all
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If you use stored frames or snapshots, run `bin/rails janela:install:migrations && bin/rails db:migrate` (see Frames below), and finish with `bin/rails janela:doctor`, which checks the install and says what to fix. The rest of this section is the JavaScript.
|
|
46
|
+
|
|
38
47
|
Then register the two Stimulus controllers. How depends on how your app ships JavaScript.
|
|
39
48
|
|
|
40
49
|
**With jsbundling (esbuild, bun, webpack):**
|
|
@@ -223,6 +232,8 @@ A doughnut or a pie is drawn on the server as SVG with a legend of buttons besid
|
|
|
223
232
|
|
|
224
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).
|
|
225
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
|
+
|
|
226
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).
|
|
227
238
|
|
|
228
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.
|
|
@@ -322,7 +333,7 @@ The engine serves an index and a page per frame at the mount root, so you can in
|
|
|
322
333
|
|
|
323
334
|
No model's route key is all digits, so a frame id and a pane URL cannot be confused. Both pages read through `policy_scope(Janela::Frame)`, so a frame your scope does not return is a 404 rather than a page, and so is a pane row under it.
|
|
324
335
|
|
|
325
|
-
These pages render in Janela's own minimal layout, which loads the gem's stylesheet and nothing else. It does not load Turbo or Stimulus, because those come from your bundler and the engine cannot name them. So Janela's own pages are correct, styled, **static** dashboards: every pane is rendered inline and the numbers are right, filters in the URL apply, and nothing cross-filters when you click. A chart pane needs Chart.js, so on these pages it
|
|
336
|
+
These pages render in Janela's own minimal layout, which loads the gem's stylesheet and nothing else. It does not load Turbo or Stimulus, because those come from your bundler and the engine cannot name them. So Janela's own pages are correct, styled, **static** dashboards: every pane is rendered inline and the numbers are right, filters in the URL apply, and nothing cross-filters when you click. A chart pane needs Chart.js, so on these pages it renders as its table (ADR 018); put a frame on your own page, where your JavaScript is, for the interactive version. Each page links back to your application's root, which you can rename in your own locale file under `janela.actions.home`.
|
|
326
337
|
|
|
327
338
|
The noun in the headings is `Janela::Frame.model_name.human`, so rename it in your own locale file rather than in a setting:
|
|
328
339
|
|
|
@@ -387,7 +398,7 @@ The model is its route key (`orders`, `sales_orders`), then the measure, then op
|
|
|
387
398
|
|
|
388
399
|
### What Janela can draw
|
|
389
400
|
|
|
390
|
-
`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):
|
|
391
402
|
|
|
392
403
|
```erb
|
|
393
404
|
<% Janela.definitions.each do |definition| %>
|
|
@@ -418,7 +429,7 @@ Render a stored pane the same way you render a live one:
|
|
|
418
429
|
<%= janela_snapshot_pane @snapshot, Order, :revenue, by: :status, as: :bar %>
|
|
419
430
|
```
|
|
420
431
|
|
|
421
|
-
Inside a dashboard a pane is a Turbo Frame and carries no layout at all. Opened directly it renders in Janela's own minimal layout, which
|
|
432
|
+
Inside a dashboard a pane is a Turbo Frame and carries no layout at all. Opened directly it renders in Janela's own minimal layout, which loads the gem's stylesheet and none of your JavaScript, because the gem cannot know your asset names or whether you bundle. A direct pane link therefore shows its numbers styled, and a bar or line chart draws nothing, since the chart needs Stimulus. A table and a ring (which is drawn as SVG on the server) still show.
|
|
422
433
|
|
|
423
434
|
To make direct pane links styled and chart-capable, give Janela a small layout of your own that loads your assets and nothing else:
|
|
424
435
|
|
|
@@ -487,25 +498,19 @@ Scoping is automatic when you use Pundit: `Janela::ApplicationController` calls
|
|
|
487
498
|
|
|
488
499
|
**Multi tenancy** has its own guide: [docs/multi-tenancy.md](docs/multi-tenancy.md). It covers what goes through your scope, worked wiring for Pundit, acts_as_tenant and CanCanCan, what owns a frame the analyst creates, and what rows a scheduled snapshot freezes.
|
|
489
500
|
|
|
490
|
-
###
|
|
501
|
+
### Giving an agent access
|
|
491
502
|
|
|
492
|
-
|
|
493
|
-
work at all, which is enough to navigate on the day you install it:
|
|
503
|
+
Frames and panes are rows, so an agent can arrange a dashboard if it has tools. Janela gives you the tool definitions as plain Ruby and leaves the registering to you, because your application knows who is asking and Janela does not (ADR 053):
|
|
494
504
|
|
|
495
|
-
```
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
```
|
|
505
|
+
```ruby
|
|
506
|
+
tools = Janela::Tools.new(scope: ->(model) { policy_scope(model) }) # read only
|
|
507
|
+
tools = Janela::Tools.new(scope: ->(model) { policy_scope(model) }, write: true)
|
|
499
508
|
|
|
500
|
-
|
|
509
|
+
Janela::Tools.all # name, description, input_schema, read_only
|
|
510
|
+
tools.call("read_pane", pane_id: 4)
|
|
511
|
+
```
|
|
501
512
|
|
|
502
|
-
|
|
503
|
-
cannot know your asset names or bundler. Two consequences worth knowing. They
|
|
504
|
-
do not cross-filter, since that needs Stimulus. And a pane whose row asks for a
|
|
505
|
-
chart renders as its **table** here, because there is no chart runtime on the
|
|
506
|
-
page and a table needs nothing: the same frame rendered in your own page with
|
|
507
|
-
`janela_frame(@frame)` draws the chart. A renderer is a viewing choice, not part
|
|
508
|
-
of the pane (ADR 018).
|
|
513
|
+
`scope:` is required and is the same answer your `policy_scope` gives, so an agent reads and changes only what the person it acts for can. The read tools list the vocabulary and frames and read a pane's values. The write tools add, change, remove and move panes, and are off unless asked for. Janela serves no MCP endpoint, edits no configuration and depends on no MCP library; [docs/agents.md](docs/agents.md) shows the few lines that register them with the official Ruby SDK.
|
|
509
514
|
|
|
510
515
|
### Checking an installation
|
|
511
516
|
|
|
@@ -558,11 +563,11 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
|
|
|
558
563
|
|
|
559
564
|
## Status
|
|
560
565
|
|
|
561
|
-
**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).
|
|
562
567
|
|
|
563
568
|
## Development
|
|
564
569
|
|
|
565
|
-
Janela is a Rails engine. It ships with a minimal host application in `test/dummy` that mounts the engine at `/
|
|
570
|
+
Janela is a Rails engine. It ships with a minimal host application in `test/dummy` that mounts the engine at `/dashboards`, so the gem is always developed and tested against a real Rails app with a real (SQLite) database.
|
|
566
571
|
|
|
567
572
|
After checking out the repo, run `bin/setup` to install dependencies. Then:
|
|
568
573
|
|
|
@@ -575,13 +580,13 @@ bin/rails console # console inside the dummy app, engine loaded
|
|
|
575
580
|
|
|
576
581
|
## Contributing
|
|
577
582
|
|
|
578
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/retail-tasker/janela. Pull requests are reviewed on the merits of the diff, whether a person or an agent wrote them. [CONTRIBUTING.md](CONTRIBUTING.md) says how to run the tests, when a change needs a decision record first, and what to write down for the people who upgrade. Contributors are expected to adhere to the [code of conduct](https://github.com/retail-tasker/janela/blob/main/CODE_OF_CONDUCT.md).
|
|
583
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/retail-tasker/janela. Pull requests are reviewed on the merits of the diff, whether a person or an agent wrote them. [CONTRIBUTING.md](https://github.com/retail-tasker/janela/blob/main/CONTRIBUTING.md) says how to run the tests, when a change needs a decision record first, and what to write down for the people who upgrade. Contributors are expected to adhere to the [code of conduct](https://github.com/retail-tasker/janela/blob/main/CODE_OF_CONDUCT.md).
|
|
579
584
|
|
|
580
585
|
## Support and security
|
|
581
586
|
|
|
582
587
|
Janela is pre-1.0 and its public surface can still move between releases, with an upgrade note each time. Issues and pull requests are read when the maintainers can get to them; there is no support contract and no promised response time.
|
|
583
588
|
|
|
584
|
-
To report a vulnerability, use the private form described in [SECURITY.md](SECURITY.md) and not a public issue. Releases are cut by the two maintainers and published from a protected workflow; the steps are in [RELEASING.md](RELEASING.md).
|
|
589
|
+
To report a vulnerability, use the private form described in [SECURITY.md](https://github.com/retail-tasker/janela/blob/main/SECURITY.md) and not a public issue. Releases are cut by the two maintainers and published from a protected workflow; the steps are in [RELEASING.md](https://github.com/retail-tasker/janela/blob/main/RELEASING.md).
|
|
585
590
|
|
|
586
591
|
## License
|
|
587
592
|
|
data/UPGRADING.md
CHANGED
|
@@ -12,6 +12,60 @@ 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
|
+
|
|
37
|
+
## 0.12.0 to 0.13.0
|
|
38
|
+
|
|
39
|
+
No migration, and nothing you must change. Two things you may see.
|
|
40
|
+
|
|
41
|
+
**The doctor treats `Janela::PanesController` as a warning.**
|
|
42
|
+
|
|
43
|
+
0.7 renamed it to `Janela::QueriesController`, and 0.12 added a
|
|
44
|
+
`Janela::PanesController` back as the stored-pane form's controller, which owns
|
|
45
|
+
`pane_params`. The doctor used to call every mention of the name an error. It now
|
|
46
|
+
warns, and says what the name means today. If you patch the form's
|
|
47
|
+
`pane_params`, leave it where it is. If you patched how a query renders, that
|
|
48
|
+
patch belongs on `Janela::QueriesController`.
|
|
49
|
+
|
|
50
|
+
**A host that copied `janela/queries/_query.html.erb` should check it.**
|
|
51
|
+
|
|
52
|
+
Rails 8.2 renders HTML templates through Herb, which refuses ERB that writes an
|
|
53
|
+
attribute's name or sits in attribute position. Janela's own copy of the partial
|
|
54
|
+
no longer does either, so it compiles. A copy of yours keeps the old markup,
|
|
55
|
+
which Herb will reject if it has ERB in an attribute position. On Rails 8.2:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
bin/rails herb:check
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
lists what it rejects. On earlier Rails there is nothing to do.
|
|
62
|
+
|
|
63
|
+
**Optional: tools for an agent.**
|
|
64
|
+
|
|
65
|
+
`Janela::Tools` gives an agent the tools to read and arrange a dashboard. It is
|
|
66
|
+
not on unless you build it, and nothing registers itself. See
|
|
67
|
+
[docs/agents.md](docs/agents.md).
|
|
68
|
+
|
|
15
69
|
## 0.11.0 to 0.12.0
|
|
16
70
|
|
|
17
71
|
One migration step, if you use stored frames.
|
|
@@ -63,7 +117,7 @@ in `--janela-series-2` to `--janela-series-8`, and every bar after the
|
|
|
63
117
|
eighth in `--janela-series-other`. Until now every bar was the accent.
|
|
64
118
|
Line charts are unchanged.
|
|
65
119
|
|
|
66
|
-
If you want the old look, set the
|
|
120
|
+
If you want the old look, set the other eight to your accent:
|
|
67
121
|
|
|
68
122
|
```css
|
|
69
123
|
:root {
|
|
@@ -549,6 +603,11 @@ Most applications do not.
|
|
|
549
603
|
`Janela::Pane` is reserved for a database record in a later release,
|
|
550
604
|
which is why the runtime object had to give the name up.
|
|
551
605
|
|
|
606
|
+
`Janela::PanesController` was renamed in this table, and 0.12 added a
|
|
607
|
+
`Janela::PanesController` back: the stored-pane form's controller, which owns
|
|
608
|
+
`pane_params`. The doctor warns about the name without calling it an error,
|
|
609
|
+
because only you know which one your code means.
|
|
610
|
+
|
|
552
611
|
If you override Janela's view, move your copy from
|
|
553
612
|
`app/views/janela/panes/` to `app/views/janela/queries/`.
|
|
554
613
|
|
|
@@ -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
|
|
@@ -69,6 +69,27 @@
|
|
|
69
69
|
janela.css draws agree with the parts this one does. */
|
|
70
70
|
--janela-line: var(--vitral-came-soft);
|
|
71
71
|
--janela-accent: rgb(40, 110, 205);
|
|
72
|
+
|
|
73
|
+
/* The colours a bar, a doughnut slice or a pie slice is drawn in, by
|
|
74
|
+
position (ADR 046, ADR 026). Soft glass rather than bright: a first
|
|
75
|
+
version in saturated jewel tones read as a rainbow against the pale tiles,
|
|
76
|
+
so these sit at about half the chroma and lean on differences in lightness
|
|
77
|
+
to keep neighbours apart (the plum and the dusty rose are the darker ones).
|
|
78
|
+
Chosen as an ordered set and run through the palette checker: adjacent
|
|
79
|
+
colour vision separation 12.4 (target 8) and adjacent normal vision
|
|
80
|
+
separation 15.8 (floor 15). The order is what keeps neighbours apart, so a
|
|
81
|
+
reorder has to be re-checked. Slot 1 is a softer blue of its own and not
|
|
82
|
+
the accent, which stays the stronger blue for what is selected. The neutral
|
|
83
|
+
past the eighth is the grey of lead came. */
|
|
84
|
+
--janela-series-1: #608fcb;
|
|
85
|
+
--janela-series-2: #56ab81;
|
|
86
|
+
--janela-series-3: #a492da;
|
|
87
|
+
--janela-series-4: #91507d;
|
|
88
|
+
--janela-series-5: #be774e;
|
|
89
|
+
--janela-series-6: #159da9;
|
|
90
|
+
--janela-series-7: #c29e51;
|
|
91
|
+
--janela-series-8: #994b59;
|
|
92
|
+
--janela-series-other: #8794a6;
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
/* Opt in, because a theme that repaints a host's <body> uninvited is not a
|
|
@@ -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 %>
|