janela 0.15.0 → 0.16.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 +22 -0
- data/README.md +23 -7
- data/UPGRADING.md +34 -0
- data/app/assets/javascripts/janela/chart_controller.js +148 -42
- data/app/assets/stylesheets/janela.css +7 -0
- data/app/controllers/janela/panes_controller.rb +2 -2
- data/app/controllers/janela/queries_controller.rb +11 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +1 -0
- data/app/helpers/janela/frames_helper.rb +35 -7
- data/app/models/janela/pane.rb +26 -2
- data/app/models/janela/query.rb +245 -25
- data/app/views/janela/frames/show.html.erb +2 -2
- data/app/views/janela/panes/_form.html.erb +46 -0
- data/app/views/janela/queries/_query.html.erb +18 -5
- data/config/locales/en.yml +19 -1
- data/db/migrate/20261029000001_add_series_to_janela_panes.rb +9 -0
- data/db/migrate/20261029000003_add_ignore_unknown_filters_to_janela_panes.rb +8 -0
- data/docs/agents.md +33 -2
- data/docs/decisions/018-a-table-is-the-universal-renderer.md +1 -0
- data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +1 -0
- data/docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md +1 -1
- data/docs/decisions/056-the-demo-counts-its-own-visits-and-an-agent-arranges-the-dashboard.md +51 -0
- data/docs/decisions/057-a-host-is-told-about-the-agent-tools-and-a-generator-sets-them-up.md +197 -0
- data/docs/decisions/058-the-deployed-demo-is-reached-over-ssh-and-its-visits-frame-survives-a-reset.md +94 -0
- data/docs/decisions/059-a-host-can-tell-janelas-own-pages-that-its-layout-draws-charts.md +91 -0
- data/docs/decisions/060-the-demo-tells-search-engines-what-it-is-and-shows-its-own-adoption.md +79 -0
- data/docs/decisions/061-the-demo-speaks-brazilian-portuguese-under-a-prefix-and-the-gem-stays-english.md +118 -0
- data/docs/decisions/062-a-time-pane-can-accumulate-and-carry-more-series-on-two-axes.md +282 -0
- data/docs/decisions/063-a-pane-can-ignore-a-filter-its-model-cannot-apply.md +147 -0
- data/docs/decisions/INDEX.md +23 -15
- data/docs/roadmap.md +6 -4
- data/lib/generators/janela/mcp/mcp_generator.rb +51 -0
- data/lib/generators/janela/mcp/templates/janela-mcp.tt +50 -0
- data/lib/janela/definition.rb +38 -2
- data/lib/janela/dimension.rb +9 -1
- data/lib/janela/doctor.rb +10 -1
- data/lib/janela/measure.rb +8 -0
- data/lib/janela/tools.rb +33 -2
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +8 -0
- metadata +20 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83aee3a06740005fd9bf9e81ddc12c6de350ea4b8f6fce34899a94869e0f2615
|
|
4
|
+
data.tar.gz: cd6d59364454c17e80302de50377a5ca2ca3f2db2ceb2bd58be8ca6a29d6b878
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 670dcac643875f4723159a3f8ecc4429d9feb115dcf58c7336fc823f8290eff4bb3c25316893dc382958753d43e689c4572416d9371b7fa36e0e23447640052c
|
|
7
|
+
data.tar.gz: 6c423db2685c60bb12a6f3878ea956fe926dfde507a9a373d71be3f42092322a22a6958ecf6cc54aee68e6535fd8690e9d4320a0a55da2aa6eed4e40074b0734
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ 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.16.0] - 2026-10-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **A time pane can read as a running total, and carry more series on two axes.** `janela_pane Order, :orders, by: :placed_on, as: :line, granularity: :month, cumulative: true` draws orders to date rather than orders per month, summed after the read so the buckets, the filters and a click on a bucket are unchanged. The same pane takes `series:`, up to three more measures of the same model over the same buckets, each optionally by a different time dimension (`by: :shipped_on`), each a running total or not, each drawn as a line or as bars (`as:`), each on the left axis or a right one (`axis:`), with a `title` for the legend or one Janela writes (`Orders by Shipped on to date`). Every series' buckets are joined into one axis and a series with no row in a bucket reads zero there (a gap, for a measure that does not add up). A chart with series shows a legend, colours by series rather than by category, draws bars behind lines, and when a series is on the right both axes are titled; a right axis holding only bars is scaled so the tallest reaches about a third of the plot. A table draws them as columns, which is the fallback with no chart runtime; a ring ignores them; a snapshot holds none. `cumulative` is refused on a category pane or a single value, and on an average, minimum, maximum or ratio, which do not add up; a set one joins the pane's frame id, and so does a digest of the series. A bucket an average, minimum, maximum or ratio series has no row in is a gap rather than a zero, and series that would span more than 10,000 buckets are refused. Both are a checkbox and plain selects on the pane form, attributes in `Janela::Tools`, and `?cumulative=1` and `series[][measure]=...` in the pane URL, each series led by its measure. Until now the chart a host wanted, sign-ups to date and activations to date as lines with new sign-ups per week as bars, was a partial pane with its own Chart.js that did not cross-filter and was not a row. Stored panes need a migration; see `UPGRADING.md` (ADR 062, #90).
|
|
13
|
+
- **A pane can ignore a filter its model cannot apply.** On a frame holding panes over two models, a click on one side wrote a key the other side's model does not declare, and that pane answered "That request is not allowed on this pane" where its number was. `janela_pane PageView, :views, by: :page, ignore_unknown_filters: true`, the checkbox on the pane form, `ignore_unknown_filters` in `Janela::Tools` or `?ignore_unknown_filters=1` in the pane URL drops the reader's keys Ransack would not apply to the pane's model before the query, so the pane draws as if they were never written. A key the model declares is applied as before, a declared key with a disallowed predicate is still refused, and a host's `where:` and a frame's `default_where` are still refused when wrong. Unset, nothing changes: a key nobody declares is still refused with its reason (ADR 025). Stored panes need a migration; see `UPGRADING.md` (ADR 063, #92).
|
|
14
|
+
|
|
15
|
+
- **A time pane's month label is written in the reader's language.** `Oct 2026` used `strftime`, which does not read a locale, so a host that set one still drew English months. It now uses the locale's abbreviated month names when the host has them (`out 2026` with Rails' Portuguese data, from the `rails-i18n` gem), and falls back to the English it always drew when it has not, so a host that never set a locale sees no change. A clicked label still maps back to its bucket through a lookup, not by reading the text, so a time click is unaffected (ADR 061).
|
|
16
|
+
|
|
17
|
+
- **`bin/rails generate janela:mcp` writes the server an agent reaches the tools through.** It creates `bin/janela-mcp`, a stdio server on the official Ruby SDK, and a `janela` entry in `.mcp.json` so Claude Code and other clients find it. Both are yours once written. The file is the adapter from `docs/agents.md` with the scope left for you to write, and until you write it every tool refuses to read with a message saying so. It refuses to run outside development. The generator leaves an existing server or entry alone, and tells you to add `gem "mcp"` rather than editing your Gemfile. The scope cannot be your controller's `policy_scope`, because outside a request that method answers a different question and reads the wrong rows (ADR 057).
|
|
18
|
+
|
|
19
|
+
- **`janela:doctor` tells a host the agent tools exist.** When `bin/janela-mcp` is missing it ends with one line, `Optional: let an agent arrange dashboards. bin/rails generate janela:mcp (docs/agents.md).` It is not a finding, so it is never counted, cannot be silenced and does not change whether the task passes. It is printed on a clean run too (ADR 057).
|
|
20
|
+
|
|
21
|
+
- **`Janela.charts_on_own_pages`, so Janela's own frame page can draw its charts.** That page showed every bar and line pane as its table, because its minimal layout loads no chart runtime and the engine cannot see whether a host's layout does (ADR 018). A host that has given those pages a layout registering the two Stimulus controllers and loading Chart.js can now say so, once, and the page draws and cross-filters like its own. It defaults to `false`, so a host that sets nothing sees exactly what it did. A host that sets it without the runtime gets an empty hole where the table was. The pane page opened directly is unchanged (ADR 059).
|
|
22
|
+
|
|
23
|
+
- **`update_frame`, a write tool that changes a frame's columns and gap.** An agent arranging a dashboard could add and move panes but not widen the grid they sit in. It takes `frame_id`, `columns` and `gap`, finds the frame through your scope like every other tool, and refuses a value Janela does not draw with the frame's own reason. A frame's name, key and owner are still yours to set, and any other attribute is refused. Off unless you build the tools with `write: true` (ADR 057).
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **`value_labels` on a line chart now writes the line's latest value at its end.** A line ignored the flag (ADR 054); it now draws one label, the last point's formatted value, in the same text colour a bar's labels use. A line that never set the flag is unchanged, and a bar chart's labels are what they were (ADR 062).
|
|
28
|
+
|
|
8
29
|
## [0.15.0] - 2026-10-13
|
|
9
30
|
|
|
10
31
|
### Added
|
|
@@ -299,6 +320,7 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
299
320
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
300
321
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
301
322
|
|
|
323
|
+
[0.16.0]: https://github.com/retail-tasker/janela/releases/tag/v0.16.0
|
|
302
324
|
[0.15.0]: https://github.com/retail-tasker/janela/releases/tag/v0.15.0
|
|
303
325
|
[0.14.0]: https://github.com/retail-tasker/janela/releases/tag/v0.14.0
|
|
304
326
|
[0.13.0]: https://github.com/retail-tasker/janela/releases/tag/v0.13.0
|
data/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It is a
|
|
|
27
27
|
|
|
28
28
|
```ruby
|
|
29
29
|
# Gemfile
|
|
30
|
-
gem "janela", "~> 0.
|
|
30
|
+
gem "janela", "~> 0.16"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
```ruby
|
|
@@ -42,7 +42,7 @@ Janela reads nothing until your `ApplicationController` says what a visitor may
|
|
|
42
42
|
private def policy_scope(model) = model.all
|
|
43
43
|
```
|
|
44
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.
|
|
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. If you want an agent to arrange dashboards for you, `bin/rails generate janela:mcp` sets that up (see Giving an agent access below). The rest of this section is the JavaScript.
|
|
46
46
|
|
|
47
47
|
Then register the two Stimulus controllers. How depends on how your app ships JavaScript.
|
|
48
48
|
|
|
@@ -163,7 +163,7 @@ measure :pass_rate, average: :score, precision: 1, suffix: "%" # 66.7%
|
|
|
163
163
|
measure :orders, count: true # 1,234
|
|
164
164
|
```
|
|
165
165
|
|
|
166
|
-
Precision defaults to what the schema already says. Counting rows has no decimal places, a `decimal(10, 2)` column reads to the cent, and summing an integer column stays whole. Declare `precision:` where the schema has nothing to say, such as averaging an integer, or where you want something else. Thousands are delimited with your app's locale.
|
|
166
|
+
Precision defaults to what the schema already says. Counting rows has no decimal places, a `decimal(10, 2)` column reads to the cent, and summing an integer column stays whole. Declare `precision:` where the schema has nothing to say, such as averaging an integer, or where you want something else. Thousands are delimited with your app's locale, and a month on a time pane is written with its month names.
|
|
167
167
|
|
|
168
168
|
Formatting is rendering, never rounding. The number itself reaches a snapshot and an order clause at full precision, so a snapshot taken last month reads back under a format you declare today.
|
|
169
169
|
|
|
@@ -234,10 +234,22 @@ A doughnut or a pie is drawn on the server as SVG with a legend of buttons besid
|
|
|
234
234
|
|
|
235
235
|
**Sorting.** A breakdown by a category is largest first by default. `sort: :asc` puts the smallest first, so `limit: 5` is the bottom five, and `sort: :label` and `sort: :label_desc` order by the dimension's own value, which suits a label whose text is already the natural order, such as a period code (`janela_pane Order, :revenue, by: :cycle, as: :bar, sort: :label`). The limit follows the order. A number or a date column sorts as itself, and text sorts by your database's collation, which is not the same everywhere. The `(none)` group is always last. Groups that tie on the measure come out by label. It is a select on the pane form, `sort:` in `Janela::Tools`, and `?sort=` in the pane URL. A time series is always in time order and ignores it (ADR 055).
|
|
236
236
|
|
|
237
|
-
**Value labels.** A bar chart shows a value only in its tooltip unless the pane asks for it on the bar: `value_labels: true` (`janela_pane Order, :expedited_rate, by: :channel, as: :bar, value_labels: true`), or the checkbox on the pane form. The label is the same string the tooltip shows, the measure's own format, drawn beyond the end of the bar: above a positive one and below a negative. It is all or none: if any label is wider than its bar's share of the chart, none is drawn, rather than some bars carrying a number and others not. Make the chart wider or `limit:` the bars. With none set nothing changes, and a
|
|
237
|
+
**Value labels.** A bar chart shows a value only in its tooltip unless the pane asks for it on the bar: `value_labels: true` (`janela_pane Order, :expedited_rate, by: :channel, as: :bar, value_labels: true`), or the checkbox on the pane form. The label is the same string the tooltip shows, the measure's own format, drawn beyond the end of the bar: above a positive one and below a negative. It is all or none: if any label is wider than its bar's share of the chart, none is drawn, rather than some bars carrying a number and others not. Make the chart wider or `limit:` the bars. On a line it writes the line's latest value, once, at the end of the line, which is what a running total wants (ADR 062). With none set nothing changes, and a ring, a table and a single value ignore it (ADR 054).
|
|
238
238
|
|
|
239
239
|
**Companion columns.** A table can carry up to three more columns beside its label, each a measure or a dimension the model declares: `janela_pane Order, :expedited_rate, by: :customer, companions: [:orders, :region]` reads as customer, expedited rate, orders and region, under a header row. A measure companion is its own number, formatted as it declares, so a rate can sit beside the count behind it: 75% of four and 75% of forty are different things to act on. A dimension companion shows a value only where every row of that label's group shares one, and is blank where they differ, since grouping by it would show a label twice and picking one would show an arbitrary value as fact. Order, limit, filters and clicking a label are the primary measure's and are unchanged. Only a table draws companions, a stored snapshot shows the base table, and a time pane may carry measures but not dimensions. Each companion is one more query, which is why it is three at most (ADR 051).
|
|
240
240
|
|
|
241
|
+
**Running totals and more series.** A time pane can read as a total to date rather than a figure per period: `cumulative: true` (`janela_pane Order, :orders, by: :placed_on, as: :line, granularity: :month, cumulative: true`), or the checkbox on the pane form. The running total is summed after the read, so the buckets, the filters and a click on a bucket are what they were, and it is refused on a category pane or a single value, or on a measure that does not add up (an average, minimum, maximum or ratio), rather than drawing something else. A set `cumulative` is part of the pane's frame id, since a measure to date and per period are two different answers that may share a page. The same pane can carry up to three more series over the same buckets, each a measure the model declares, optionally over a different time dimension of the same model, each drawn as a line or as bars, on the left axis or a right one:
|
|
242
|
+
|
|
243
|
+
```erb
|
|
244
|
+
<%= janela_pane Order, :orders, by: :placed_on, as: :line, granularity: :month, cumulative: true,
|
|
245
|
+
series: [
|
|
246
|
+
{ measure: :orders, by: :shipped_on, cumulative: true, title: "Orders shipped to date" },
|
|
247
|
+
{ measure: :orders, as: :bar, axis: :right, title: "Orders per month" }
|
|
248
|
+
] %>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
A series takes `measure`, `by` (a time dimension; the pane's own when left out), `cumulative`, `as` (`line` or `bar`; the pane's renderer when it is one of those, else `line`), `axis` (`left` or `right`) and `title`. Every series' buckets are joined into one axis, from the first bucket any of them has to the last, and a series with no row in a bucket reads zero there (a gap for an average, minimum, maximum or ratio, which have no value in an empty bucket), so `Orders shipped to date` is flat across a month nothing shipped. A series by another time dimension counts rows where that column falls in the bucket, so a null is not counted anywhere. The title Janela writes when you leave it out is the measure's name, then `by` and the dimension's when it is not the pane's, then `to date` when it accumulates. A chart with series shows a legend and colours by series, `--janela-series-1` for the pane's own and the rest in order, bars behind lines; a right axis exists only when a series asks for it, and then both axes are titled with the series on them, and a right axis that holds only bars is scaled so the tallest reaches about a third of the plot. A table draws the series as columns after the measure's, which is also what a surface with no chart runtime shows; a ring ignores them and a stored snapshot holds none. On a chart with series, `value_labels` writes each line's latest value at the end of the line and labels no bar. Filters narrow every series the same way, and a click on the chart writes the pane's own dimension's range. It is rows on the pane form, `cumulative:` and `series:` in `Janela::Tools`, and `?cumulative=1` and `series[][measure]=orders&series[][by]=shipped_on` in the pane URL, each series led by its measure (ADR 062).
|
|
252
|
+
|
|
241
253
|
**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.
|
|
242
254
|
|
|
243
255
|
```erb
|
|
@@ -335,7 +347,7 @@ The engine serves an index and a page per frame at the mount root, so you can in
|
|
|
335
347
|
|
|
336
348
|
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.
|
|
337
349
|
|
|
338
|
-
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`.
|
|
350
|
+
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. If you have given these pages a layout of your own that registers the two Stimulus controllers and loads Chart.js, say so with `Janela.charts_on_own_pages = true` in `config/initializers/janela.rb`, and the frame page draws its charts and cross-filters too (ADR 059). Leave it off otherwise: the engine cannot see what your layout loads, and a chart with no runtime is an empty hole. Each page links back to your application's root, which you can rename in your own locale file under `janela.actions.home`.
|
|
339
351
|
|
|
340
352
|
The noun in the headings is `Janela::Frame.model_name.human`, so rename it in your own locale file rather than in a setting:
|
|
341
353
|
|
|
@@ -378,6 +390,8 @@ A host with no tenancy defines nothing, gets a nil owner, and is correct: nothin
|
|
|
378
390
|
|
|
379
391
|
The dashboard's filters live in the page URL as the same `q[...]` parameters, so a reload keeps them and a filtered dashboard is a link you can send: `/reports/orders?q[status_in][]=paid` renders filtered before any JavaScript runs. A pane ignores filters on its own dimension, so clicking a value re-scopes the rest of the dashboard rather than collapsing the pane you clicked. Clicking a bucket on a time pane filters the others to the range it covers. Every selected value is marked `aria-pressed="true"` on tables and drawn solid against faded siblings on charts, so it can be styled and read.
|
|
380
392
|
|
|
393
|
+
**A frame over two models.** Every pane is handed the frame's filters, and a pane refuses a key its model does not declare, because Ransack would otherwise drop it and show an unfiltered number (ADR 025). On a frame that holds panes over two models that refusal reaches a click it should not: `paid` clicked on an orders pane is `q[status_in][]=paid` to a page views pane, which has no status and would answer "That request is not allowed on this pane" where its number was. A pane can say it ignores what it cannot apply: `janela_pane PageView, :views, by: :page, ignore_unknown_filters: true`, or the checkbox on the pane form, or `?ignore_unknown_filters=1` in the pane URL. The keys Ransack would drop are dropped before the query, a key the model does declare is applied as on any pane, a declared key with a predicate the dimension does not allow is still refused, and a host's `where:` and a frame's `default_where` are still refused when wrong. Unset, a pane is what it always was, so a typo on a pane that never meets another model still fails loudly. Two models share a click only when they spell the key the same, which for a dimension reached through an association is `<association>_<column>` (ADR 063).
|
|
394
|
+
|
|
381
395
|
**Selecting more than one.** Ctrl or Cmd click adds a value to the selection and takes it out again, leaving the rest alone, which is how every list in every operating system already behaves. A plain click selects one value and replaces whatever was selected, or clears the dimension if that value was the only one. It works the same on a chart. The (none) group, the rows that have nothing there, is a member of the selection like any value: Ctrl or Cmd click it beside a value and the panes show the rows that have that value or nothing (`q[channel_in][]=web&q[channel_null]=1`), where `not_in` and `not_null` together still mean neither (ADR 049). All of it works from the keyboard too: a value is a real `<button>`, so Enter is a click and Ctrl or Cmd with Enter adds. `Escape` clears the frame's filters, and those are the only two keys Janela binds, both only while focus is inside the frame, because a single letter belongs to your application and to any text field on the page (ADR 024). A pane with no matching rows renders a `.janela-empty` paragraph. A group whose dimension is null is labelled `(none)` and filters with Ransack's null predicate rather than an empty string. Only models that declare a `janela` block can be requested over HTTP.
|
|
382
396
|
|
|
383
397
|
### Pane URLs
|
|
@@ -392,6 +406,8 @@ Every pane has its own URL under the mount, and a Turbo Frame in a dashboard loa
|
|
|
392
406
|
orders revenue by region where status is paid
|
|
393
407
|
/dashboards/orders/revenue/placed_on?granularity=week&as=line
|
|
394
408
|
orders revenue by placed_on, per week, as a line
|
|
409
|
+
/dashboards/orders/orders/placed_on?cumulative=1&series[][measure]=orders&series[][by]=shipped_on&series[][cumulative]=1
|
|
410
|
+
orders to date by placed_on, with orders by shipped_on to date beside it
|
|
395
411
|
```
|
|
396
412
|
|
|
397
413
|
The model is its route key (`orders`, `sales_orders`), then the measure, then optionally the dimension. Where an analyst would say *by*, the URL has a `/`; *where* is a `q` filter; *as a bar chart* is `?as=bar`; *top ten* is `?limit=10`; *as of* a snapshot is `/snapshots/:id/` in front. Category panes are always ordered by the measure, largest first; time panes are chronological. A pane opened on its own renders with its filters applied, so a filtered pane is a link you can send someone. ADR 005 has the grammar, ADR 011 the layout it renders in.
|
|
@@ -512,7 +528,7 @@ Janela::Tools.all # name, description, input_schema, read_only
|
|
|
512
528
|
tools.call("read_pane", pane_id: 4)
|
|
513
529
|
```
|
|
514
530
|
|
|
515
|
-
`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
|
|
531
|
+
`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 change a frame's columns and gap, and are off unless asked for. `bin/rails generate janela:mcp` writes the server file and the `.mcp.json` entry so an agent can reach them, and you write the scope in it. Janela serves no MCP endpoint, edits nothing it did not create and depends on no MCP library; [docs/agents.md](docs/agents.md) has the rest.
|
|
516
532
|
|
|
517
533
|
### Checking an installation
|
|
518
534
|
|
|
@@ -565,7 +581,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
|
|
|
565
581
|
|
|
566
582
|
## Status
|
|
567
583
|
|
|
568
|
-
**v0.
|
|
584
|
+
**v0.16.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar, line, doughnut and pie charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames found by owner and key, panes that hold words or a host partial as well as a query, a host-fixed filter no click can remove and a frame's own permanent one beside it, STI subclasses, the engine's own pages for reading and editing them, bar charts that read in their measure's format and can show each bar's value, category panes that can be ordered either way by their measure or by their label, tools an agent can use to read and arrange a dashboard, and the optional vitral theme work and are covered by unit and real-browser tests, with the classes a theme may target documented in [Theming Janela](docs/theming.md). Not yet built: a visual editor, and narrowing a time pane's own granularity by clicking one of its buckets (a click on a bucket does filter the others). [Vista](docs/roadmap.md), the roadmap, says what 1.0 means and which of these are in it; open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
|
|
569
585
|
|
|
570
586
|
## Development
|
|
571
587
|
|
data/UPGRADING.md
CHANGED
|
@@ -12,6 +12,40 @@ bin/rails janela:doctor
|
|
|
12
12
|
|
|
13
13
|
It reads your application and lists what still needs changing.
|
|
14
14
|
|
|
15
|
+
## 0.15.0 to 0.16.0
|
|
16
|
+
|
|
17
|
+
One migration step, if you use stored frames.
|
|
18
|
+
|
|
19
|
+
**Take the pane migration.**
|
|
20
|
+
|
|
21
|
+
A time pane can now be a running total and carry further series (ADR 062),
|
|
22
|
+
and a pane can ignore a filter its model cannot apply (ADR 063).
|
|
23
|
+
`janela_panes` gains a nullable boolean `cumulative`, a nullable JSON
|
|
24
|
+
`series` and a nullable boolean `ignore_unknown_filters`:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bin/rails janela:install:migrations
|
|
28
|
+
bin/rails db:migrate
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Every existing pane keeps all three nil and is drawn exactly as before. If you
|
|
32
|
+
never use stored frames, there is nothing to do. `bin/rails janela:doctor`
|
|
33
|
+
names the columns if a stored-frames host skips the step.
|
|
34
|
+
|
|
35
|
+
**A line chart with `value_labels` gains one label.** Nothing to change: a
|
|
36
|
+
line that asked for value labels and got none now has its latest value
|
|
37
|
+
written at its end. A host that screenshots such a chart will see the new
|
|
38
|
+
text.
|
|
39
|
+
|
|
40
|
+
**If you built a combined chart as a partial pane,** with its own Chart.js
|
|
41
|
+
controller, it can become a query pane: `cumulative: true` and `series:` on
|
|
42
|
+
`janela_pane`, or the same on the pane form. The partial keeps working as it
|
|
43
|
+
is; it just never cross-filtered.
|
|
44
|
+
|
|
45
|
+
**A pane that ignores filters still refuses a misused real key.** Nothing to
|
|
46
|
+
change: `ignore_unknown_filters: true` drops only keys the pane's model does
|
|
47
|
+
not declare.
|
|
48
|
+
|
|
15
49
|
## 0.14.0 to 0.15.0
|
|
16
50
|
|
|
17
51
|
One migration step, if you use stored frames.
|
|
@@ -7,9 +7,15 @@ Chart.register(...registerables)
|
|
|
7
7
|
// toggle event a table button emits, so the frame controller cannot tell the
|
|
8
8
|
// difference. Turbo replaces the turbo frame on every cross-filter, so the
|
|
9
9
|
// chart is destroyed on disconnect and rebuilt on connect.
|
|
10
|
+
//
|
|
11
|
+
// labels, values and formatted are the pane's own series. A time pane may
|
|
12
|
+
// carry further series, each a dataset of its own on the left axis or a
|
|
13
|
+
// right one, and then the chart has a legend and both axes are titled
|
|
14
|
+
// (ADR 062). Without them nothing here is different from before.
|
|
10
15
|
export default class extends Controller {
|
|
11
16
|
static values = { type: String, labels: Array, values: Array, filters: Object, title: String,
|
|
12
|
-
selected: Array, formatted: Array, tickFormat: Object, fixedHeight: Boolean, valueLabels: Boolean
|
|
17
|
+
selected: Array, formatted: Array, tickFormat: Object, fixedHeight: Boolean, valueLabels: Boolean,
|
|
18
|
+
label: String, series: Array, axes: Object }
|
|
13
19
|
|
|
14
20
|
connect() {
|
|
15
21
|
const controller = this
|
|
@@ -20,13 +26,7 @@ export default class extends Controller {
|
|
|
20
26
|
plugins: this.valueLabelsValue ? [ { id: "janelaValueLabels", afterDatasetsDraw: (chart) => this.drawValueLabels(chart) } ] : [],
|
|
21
27
|
data: {
|
|
22
28
|
labels: this.labelsValue,
|
|
23
|
-
datasets: [
|
|
24
|
-
label: this.titleValue,
|
|
25
|
-
data: this.valuesValue,
|
|
26
|
-
backgroundColor: this.colours(),
|
|
27
|
-
borderColor: this.typeValue === "bar" ? this.colours(0.9) : this.accentColour(0.9),
|
|
28
|
-
...this.pointStyle()
|
|
29
|
-
}]
|
|
29
|
+
datasets: [ this.primaryDataset(), ...this.seriesValue.map((series, index) => this.seriesDataset(series, index + 1)) ]
|
|
30
30
|
},
|
|
31
31
|
options: {
|
|
32
32
|
animation: false,
|
|
@@ -35,24 +35,18 @@ export default class extends Controller {
|
|
|
35
35
|
// is decided here, when the chart is made: patched onto a chart built
|
|
36
36
|
// with it on, it draws at the wrong size.
|
|
37
37
|
maintainAspectRatio: !this.fixedHeightValue,
|
|
38
|
-
|
|
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
|
-
} },
|
|
38
|
+
scales: this.scales(),
|
|
46
39
|
// A line is clicked anywhere along its x position rather than on
|
|
47
40
|
// the exact pixel of a point, which on a dense series is a few
|
|
48
|
-
// pixels wide (ADR 045).
|
|
49
|
-
|
|
41
|
+
// pixels wide (ADR 045). With several series a bucket is read across
|
|
42
|
+
// all of them at once.
|
|
43
|
+
interaction: this.interaction(),
|
|
50
44
|
plugins: {
|
|
51
|
-
legend: { display:
|
|
45
|
+
legend: { display: this.hasSeries() },
|
|
52
46
|
// The server formatted every number for the table, so the tooltip
|
|
53
47
|
// reads the same string rather than formatting a second time here
|
|
54
48
|
// and disagreeing with the cell beside it.
|
|
55
|
-
tooltip: { callbacks: { label: (item) => this.
|
|
49
|
+
tooltip: { callbacks: { label: (item) => this.formattedFor(item.datasetIndex)[item.dataIndex] ?? item.formattedValue } }
|
|
56
50
|
},
|
|
57
51
|
onClick: (event, elements) => {
|
|
58
52
|
if (elements.length === 0) return
|
|
@@ -73,30 +67,133 @@ export default class extends Controller {
|
|
|
73
67
|
})
|
|
74
68
|
}
|
|
75
69
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
//
|
|
70
|
+
hasSeries() {
|
|
71
|
+
return this.seriesValue.length > 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The pane's own series. Alone it is what it always was: bars coloured by
|
|
75
|
+
// position and a line in the accent. Beside further series it is the first
|
|
76
|
+
// colour of the palette, and the others follow in order (ADR 062).
|
|
77
|
+
primaryDataset() {
|
|
78
|
+
const property = this.hasSeries() ? this.seriesProperty(0) : null
|
|
79
|
+
return {
|
|
80
|
+
label: this.hasSeries() ? this.labelValue : this.titleValue,
|
|
81
|
+
data: this.valuesValue,
|
|
82
|
+
type: this.typeValue,
|
|
83
|
+
yAxisID: "y",
|
|
84
|
+
order: this.typeValue === "bar" ? 2 : 1,
|
|
85
|
+
backgroundColor: this.colours(0.25, property, this.typeValue === "bar" && property ? 0.55 : 0.9),
|
|
86
|
+
borderColor: this.typeValue === "bar" ? this.colours(0.9, property, property ? 0.55 : 0.9) : (property ? this.colour(property, 0.9) : this.accentColour(0.9)),
|
|
87
|
+
...this.pointStyle(this.typeValue, property)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Bars are drawn behind lines and quieter than them, so a line stays the
|
|
92
|
+
// story over the bars under it.
|
|
93
|
+
seriesDataset(series, position) {
|
|
94
|
+
const property = this.seriesProperty(position)
|
|
95
|
+
return {
|
|
96
|
+
label: series.label,
|
|
97
|
+
data: series.values,
|
|
98
|
+
type: series.type,
|
|
99
|
+
yAxisID: series.axis === "right" ? "y1" : "y",
|
|
100
|
+
order: series.type === "bar" ? 2 : 1,
|
|
101
|
+
backgroundColor: series.type === "bar" ? this.colours(0.2, property, 0.55) : this.colours(0.25, property),
|
|
102
|
+
borderColor: series.type === "bar" ? this.colours(0.2, property, 0.55) : this.colour(property, 0.9),
|
|
103
|
+
...this.pointStyle(series.type, property)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
formattedFor(datasetIndex) {
|
|
108
|
+
return datasetIndex === 0 ? this.formattedValue : (this.seriesValue[datasetIndex - 1]?.formatted ?? [])
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interaction() {
|
|
112
|
+
if (this.hasSeries()) return { mode: "index", intersect: false }
|
|
113
|
+
return this.typeValue === "line" ? { mode: "nearest", axis: "x", intersect: false } : undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Chart.js picks the ticks, so the server cannot format them; each is
|
|
117
|
+
// formatted here with what the measure declares (ADR 054). A right axis
|
|
118
|
+
// exists only when a series asks for it, draws no grid across the plot,
|
|
119
|
+
// and both axes are then titled with the series on them. When every series
|
|
120
|
+
// on the right is bars, that axis is scaled so the tallest bar reaches
|
|
121
|
+
// about a third of the plot, so bars sit under the lines (ADR 062).
|
|
122
|
+
scales() {
|
|
123
|
+
const controller = this
|
|
124
|
+
const ticks = { callback(value, index, ticks) { return controller.tickLabel(this, value, index, ticks) } }
|
|
125
|
+
const scales = { y: {
|
|
126
|
+
beginAtZero: true,
|
|
127
|
+
// Room above the tallest bar, and below the lowest, for its label (ADR 054).
|
|
128
|
+
grace: this.valueLabelsValue ? "10%" : undefined,
|
|
129
|
+
title: this.axisTitle(this.axesValue.left),
|
|
130
|
+
ticks
|
|
131
|
+
} }
|
|
132
|
+
|
|
133
|
+
const right = this.seriesValue.filter((series) => series.axis === "right")
|
|
134
|
+
if (right.length > 0) {
|
|
135
|
+
const barsOnly = right.every((series) => series.type === "bar")
|
|
136
|
+
const tallest = Math.max(0, ...right.flatMap((series) => series.values))
|
|
137
|
+
scales.y1 = {
|
|
138
|
+
position: "right",
|
|
139
|
+
beginAtZero: true,
|
|
140
|
+
suggestedMax: barsOnly && tallest > 0 ? tallest * 3 : undefined,
|
|
141
|
+
grid: { drawOnChartArea: false },
|
|
142
|
+
title: this.axisTitle(this.axesValue.right),
|
|
143
|
+
ticks
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return scales
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
axisTitle(text) {
|
|
150
|
+
return text ? { display: true, text } : undefined
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// With value labels asked for: on a lone bar chart the same string the
|
|
154
|
+
// tooltip shows at the end of each bar, above a positive one and below a
|
|
155
|
+
// negative, all of them or none, since a label wider than its bar's slot
|
|
156
|
+
// would overlap its neighbour and hiding only the ones that collide would
|
|
157
|
+
// label some bars for a reason a reader cannot see (ADR 054). On a line its
|
|
158
|
+
// latest value, once, at the end of the line, which is what a running total
|
|
159
|
+
// wants (ADR 062). Bars beside other series draw none: their slots are
|
|
160
|
+
// shared, and all or none would almost always say none.
|
|
81
161
|
drawValueLabels(chart) {
|
|
82
|
-
const bars = chart.getDatasetMeta(0).data
|
|
83
|
-
const values = chart.data.datasets[0].data
|
|
84
162
|
const { ctx } = chart
|
|
85
163
|
const { size, family } = Chart.defaults.font
|
|
86
|
-
const slot = chart.chartArea.width / bars.length
|
|
87
|
-
|
|
88
164
|
ctx.save()
|
|
89
165
|
ctx.font = `${size}px ${family}`
|
|
90
|
-
if (this.formattedValue.some((text) => ctx.measureText(text).width > slot)) return ctx.restore()
|
|
91
|
-
|
|
92
166
|
ctx.fillStyle = Chart.defaults.color
|
|
167
|
+
|
|
168
|
+
chart.data.datasets.forEach((dataset, index) => {
|
|
169
|
+
// A dataset hidden from the legend draws nothing, labels included.
|
|
170
|
+
if (!chart.isDatasetVisible(index)) return
|
|
171
|
+
const formatted = this.formattedFor(index)
|
|
172
|
+
const points = chart.getDatasetMeta(index).data
|
|
173
|
+
if (dataset.type === "line") {
|
|
174
|
+
const last = points.at(-1)
|
|
175
|
+
if (!last || formatted.length === 0) return
|
|
176
|
+
ctx.textAlign = "right"
|
|
177
|
+
ctx.textBaseline = "bottom"
|
|
178
|
+
ctx.fillText(formatted.at(-1), last.x, last.y - 6)
|
|
179
|
+
} else if (!this.hasSeries()) {
|
|
180
|
+
this.drawBarLabels(chart, points, dataset.data, formatted)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
ctx.restore()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
drawBarLabels(chart, bars, values, formatted) {
|
|
187
|
+
const { ctx } = chart
|
|
188
|
+
const slot = chart.chartArea.width / bars.length
|
|
189
|
+
if (formatted.some((text) => ctx.measureText(text).width > slot)) return
|
|
190
|
+
|
|
93
191
|
ctx.textAlign = "center"
|
|
94
192
|
bars.forEach((bar, index) => {
|
|
95
193
|
const negative = values[index] < 0
|
|
96
194
|
ctx.textBaseline = negative ? "top" : "bottom"
|
|
97
|
-
ctx.fillText(
|
|
195
|
+
ctx.fillText(formatted[index], bar.x, negative ? bar.y + 4 : bar.y - 4)
|
|
98
196
|
})
|
|
99
|
-
ctx.restore()
|
|
100
197
|
}
|
|
101
198
|
|
|
102
199
|
// Chart.js's own numeric formatter, so the decimals follow the spacing
|
|
@@ -105,8 +202,15 @@ export default class extends Controller {
|
|
|
105
202
|
// (ADR 038), so its ticks are scaled by 100, rounded first because 0.035 * 100
|
|
106
203
|
// is 3.5000000000000004. The measure's precision is not used: it says what one
|
|
107
204
|
// value means, and would print 20.0% at every tick.
|
|
205
|
+
// The right axis reads in the format of the series on it, so it agrees with
|
|
206
|
+
// the tooltip. Where several series share it, the first one speaks.
|
|
207
|
+
tickFormatFor(scale) {
|
|
208
|
+
if (scale.id !== "y1") return this.tickFormatValue
|
|
209
|
+
return this.seriesValue.find((series) => series.axis === "right")?.tick_format ?? this.tickFormatValue
|
|
210
|
+
}
|
|
211
|
+
|
|
108
212
|
tickLabel(scale, value, index, ticks) {
|
|
109
|
-
const { prefix = "", suffix = "", ratio = false } = this.
|
|
213
|
+
const { prefix = "", suffix = "", ratio = false } = this.tickFormatFor(scale)
|
|
110
214
|
const factor = ratio ? 100 : 1
|
|
111
215
|
const scaled = (number) => Number((number * factor).toPrecision(12))
|
|
112
216
|
const number = Ticks.formatters.numeric.call(scale, scaled(value), index, ticks.map((tick) => ({ ...tick, value: scaled(tick.value) })))
|
|
@@ -116,27 +220,29 @@ export default class extends Controller {
|
|
|
116
220
|
// With nothing selected every bar is solid; with a selection only the
|
|
117
221
|
// selected ones are, and there can be more than one of them. A bar takes
|
|
118
222
|
// the colour for its position in the palette (ADR 046); a line is one
|
|
119
|
-
// series and keeps the accent.
|
|
120
|
-
|
|
223
|
+
// series and keeps the accent. A dataset beside other series is given its
|
|
224
|
+
// own property instead and every bucket takes that one colour, and a bar
|
|
225
|
+
// there is quieter than a line (ADR 062).
|
|
226
|
+
colours(unselected = 0.25, property = null, solidAlpha = 0.9) {
|
|
121
227
|
const selected = this.selectedValue.map(String)
|
|
122
228
|
return this.labelsValue.map((label, index) => {
|
|
123
229
|
const solid = selected.length === 0 || selected.includes(String(label))
|
|
124
|
-
const
|
|
125
|
-
return this.colour(
|
|
230
|
+
const chosen = property || (this.typeValue === "bar" ? this.seriesProperty(index) : "--janela-accent")
|
|
231
|
+
return this.colour(chosen, solid ? solidAlpha : unselected)
|
|
126
232
|
})
|
|
127
233
|
}
|
|
128
234
|
|
|
129
235
|
// A line shows its selection on its points: the buckets inside the range
|
|
130
236
|
// are solid and larger, the rest faded. A dense series draws no points
|
|
131
237
|
// until one is hovered, so it stays a line (ADR 045).
|
|
132
|
-
pointStyle() {
|
|
133
|
-
if (
|
|
238
|
+
pointStyle(type = this.typeValue, property = null) {
|
|
239
|
+
if (type !== "line") return {}
|
|
134
240
|
|
|
135
241
|
const selected = this.selectedValue.map(String)
|
|
136
242
|
const dense = this.labelsValue.length > 60
|
|
137
243
|
return {
|
|
138
|
-
pointBackgroundColor: this.colours(),
|
|
139
|
-
pointBorderColor: this.colours(),
|
|
244
|
+
pointBackgroundColor: this.colours(0.25, property),
|
|
245
|
+
pointBorderColor: this.colours(0.25, property),
|
|
140
246
|
pointRadius: this.labelsValue.map((label) => selected.includes(String(label)) ? 5 : (dense ? 0 : 3)),
|
|
141
247
|
pointHoverRadius: 6
|
|
142
248
|
}
|
|
@@ -225,6 +225,13 @@ table.janela-legend button[aria-pressed="true"] { background: var(--janela-accen
|
|
|
225
225
|
background: transparent;
|
|
226
226
|
color: inherit;
|
|
227
227
|
}
|
|
228
|
+
/* One further series per row on the pane form (ADR 062): its measure first,
|
|
229
|
+
then what is left unsaid about it, each a plain control. */
|
|
230
|
+
.janela-series { border: 1px solid var(--janela-line); border-radius: calc(var(--janela-space) * 1.5); padding: calc(var(--janela-space) * 3); }
|
|
231
|
+
.janela-series legend { font-size: 0.875rem; font-weight: 600; padding: 0 calc(var(--janela-space) * 1); }
|
|
232
|
+
.janela-series-slot { display: flex; flex-wrap: wrap; align-items: center; gap: calc(var(--janela-space) * 2); padding-top: calc(var(--janela-space) * 3); }
|
|
233
|
+
.janela-series-slot label { font-weight: 400; }
|
|
234
|
+
.janela-series-slot input[type="text"] { flex: 1 1 10rem; }
|
|
228
235
|
.janela-actions { display: flex; flex-wrap: wrap; gap: calc(var(--janela-space) * 3); align-items: center; }
|
|
229
236
|
.janela-actions form { display: inline; }
|
|
230
237
|
.janela-button {
|
|
@@ -76,8 +76,8 @@ module Janela
|
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
def pane_params
|
|
79
|
-
params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :sort, :height, :prominence, :value_labels, :span, :title,
|
|
80
|
-
:heading, :body, :link, :partial, { companions: [] } ])
|
|
79
|
+
params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :sort, :height, :prominence, :value_labels, :cumulative, :ignore_unknown_filters, :span, :title,
|
|
80
|
+
:heading, :body, :link, :partial, { companions: [], series: [ Janela::Query::SERIES_KEYS.map(&:to_sym) ] } ])
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
def build_pane(choice)
|
|
@@ -13,11 +13,22 @@ module Janela
|
|
|
13
13
|
prominence: params[:prominence],
|
|
14
14
|
value_labels: params[:value_labels],
|
|
15
15
|
companions: params[:companions],
|
|
16
|
+
cumulative: params[:cumulative],
|
|
17
|
+
series: series_params,
|
|
18
|
+
ignore_unknown_filters: params[:ignore_unknown_filters],
|
|
16
19
|
filters: filters,
|
|
17
20
|
fixed: fixed_filters
|
|
18
21
|
)
|
|
19
22
|
|
|
20
23
|
@result = @query.result(on: janela_scope(@query.model))
|
|
21
24
|
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
# A list of hashes, each led by its measure (ADR 062). Anything else in
|
|
28
|
+
# the parameter is handed to the query to be refused with its reason.
|
|
29
|
+
def series_params
|
|
30
|
+
given = params[:series]
|
|
31
|
+
given.is_a?(Array) ? given.map { |each| each.respond_to?(:permit!) ? each.permit!.to_h : each } : given
|
|
32
|
+
end
|
|
22
33
|
end
|
|
23
34
|
end
|
|
@@ -34,26 +34,36 @@ module Janela
|
|
|
34
34
|
# server draws the pane from it again on every cross-filter. It is not part
|
|
35
35
|
# of the frame's id: how tall a pane is does not say which query it is
|
|
36
36
|
# (ADR 047, ADR 029).
|
|
37
|
-
|
|
37
|
+
#
|
|
38
|
+
# cumulative: is a running total across a time pane's buckets, and series:
|
|
39
|
+
# a list of further measures drawn beside it (ADR 062). Both travel in the
|
|
40
|
+
# URL for the same reason; a set cumulative is also part of the frame's
|
|
41
|
+
# id, since a measure to date and per period are two different answers.
|
|
42
|
+
# ignore_unknown_filters: drops the reader's filters this pane's model cannot
|
|
43
|
+
# apply rather than refusing them, for a pane beside panes over another
|
|
44
|
+
# model (ADR 063).
|
|
45
|
+
def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, cumulative: nil, series: nil, ignore_unknown_filters: nil, id: nil)
|
|
38
46
|
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, sort: sort, height: height, prominence: prominence,
|
|
39
|
-
value_labels: (1 if value_labels), companions: companions.presence,
|
|
47
|
+
value_labels: (1 if value_labels), companions: companions.presence, cumulative: janela_cumulative_param(cumulative), series: janela_series_params(series),
|
|
48
|
+
ignore_unknown_filters: (1 if ignore_unknown_filters),
|
|
40
49
|
where: @janela_fixed_filters.presence }.compact
|
|
41
50
|
base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
|
|
42
51
|
|
|
43
|
-
turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, sort: sort),
|
|
52
|
+
turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, sort: sort, cumulative: cumulative, series: series),
|
|
44
53
|
src: janela_with_page_filters(base),
|
|
45
54
|
loading: :lazy,
|
|
46
55
|
data: { janela__frame_target: "pane", janela_src: base }
|
|
47
56
|
end
|
|
48
57
|
|
|
49
58
|
# A pane as it was when the snapshot was taken: same shape as janela_pane,
|
|
50
|
-
# not part of the live frame's filter state (ADR 009).
|
|
51
|
-
|
|
59
|
+
# not part of the live frame's filter state (ADR 009). A snapshot holds
|
|
60
|
+
# no series, as it holds no companions, so it takes none (ADR 062).
|
|
61
|
+
def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, cumulative: nil)
|
|
52
62
|
query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, sort: sort, height: height,
|
|
53
|
-
prominence: prominence, value_labels: (1 if value_labels), companions: companions.presence }.compact
|
|
63
|
+
prominence: prominence, value_labels: (1 if value_labels), companions: companions.presence, cumulative: janela_cumulative_param(cumulative) }.compact
|
|
54
64
|
src = janela_routes.snapshot_pane_path(snapshot, model.model_name.route_key, measure, by, **query)
|
|
55
65
|
|
|
56
|
-
turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, sort: sort, snapshot: snapshot),
|
|
66
|
+
turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, sort: sort, cumulative: cumulative, snapshot: snapshot),
|
|
57
67
|
src: src, loading: :lazy
|
|
58
68
|
end
|
|
59
69
|
|
|
@@ -74,6 +84,24 @@ module Janela
|
|
|
74
84
|
end
|
|
75
85
|
|
|
76
86
|
private
|
|
87
|
+
# Read the way the server reads the flag, so "0" and "false" are no in
|
|
88
|
+
# the URL and in the frame's id alike.
|
|
89
|
+
def janela_cumulative_param(cumulative)
|
|
90
|
+
1 if Query.flag!("cumulative", cumulative)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Each series led by its measure, because Rack opens a new hash in
|
|
94
|
+
# series[][...] where a key repeats and otherwise adds to the last one,
|
|
95
|
+
# and to_query keeps the order it is given inside a [] (ADR 062).
|
|
96
|
+
def janela_series_params(series)
|
|
97
|
+
return if series.blank?
|
|
98
|
+
|
|
99
|
+
series.map do |each|
|
|
100
|
+
given = each.to_h.symbolize_keys
|
|
101
|
+
{ measure: given[:measure] }.merge(given.except(:measure)).compact
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
77
105
|
# Wired once per frame, so a host asks a pane to go to a different query
|
|
78
106
|
# by dispatching an event from anywhere inside rather than by reaching
|
|
79
107
|
# for the controller itself (ADR 030).
|