janela 0.9.0 → 0.11.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 +31 -0
- data/README.md +15 -5
- data/UPGRADING.md +81 -0
- data/app/assets/javascripts/janela/chart_controller.js +76 -10
- data/app/assets/javascripts/janela/frame_controller.js +25 -5
- data/app/assets/stylesheets/janela.css +41 -0
- data/app/models/janela/frame.rb +29 -0
- data/app/models/janela/pane.rb +2 -2
- data/app/models/janela/query.rb +144 -12
- data/app/views/janela/queries/_query.html.erb +14 -11
- data/app/views/janela/queries/_ring.html.erb +41 -0
- data/config/locales/en.yml +6 -0
- data/db/migrate/20260928000001_add_default_filter_to_janela_frames.rb +10 -0
- data/docs/composing.md +19 -0
- data/docs/decisions/006-time-dimensions-with-groupdate.md +1 -1
- data/docs/decisions/043-a-frames-default-filter-names-the-model-it-narrows.md +163 -0
- data/docs/decisions/044-a-categorical-dimension-can-say-what-it-excludes.md +140 -0
- data/docs/decisions/045-clicking-a-time-bucket-filters-the-frame-to-its-range.md +206 -0
- data/docs/decisions/046-a-ring-is-server-drawn-svg-and-a-palette-is-eight-fixed-colours.md +199 -0
- data/docs/decisions/INDEX.md +15 -11
- data/docs/roadmap.md +15 -20
- data/docs/theming.md +14 -2
- data/lib/janela/definition.rb +20 -3
- data/lib/janela/dimension.rb +31 -2
- data/lib/janela/version.rb +1 -1
- metadata +15 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2079a652eafc71edc8c31227d2c09ef8e795a38da68b8ec6c18de89732a54f97
|
|
4
|
+
data.tar.gz: b024b3337cd75f63619ff8cb844a6887e2b4ecb0bf7cb510ceb2fea789537b03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 949892c698668e8a170d5c2dedadeff080f62aee007cf86a89537c62eb304fd75fa479e4d4eb9635e18619794e3003541d90035711e6aa1304771d4f84a1d954
|
|
7
|
+
data.tar.gz: e445c3469c68b7f9b5e20d384bbd0589b0ec98f5beeacdbbb48b0f371e0f67771448d42b8240dc859c5fe11e5459d66b57f7652cb2106e56346adb49fd453693
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ 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.11.0] - 2026-09-30
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Clicking a bucket on a time pane filters the other panes to it.** A day on a line chart, or a row of a time table, now selects the range that bucket covers: the start of the bucket and the start of the next, written as the dimension's `_gteq` and `_lt` (`q[placed_on_gteq]=2026-09-01&q[placed_on_lt]=2026-10-01`). Until now a time pane ignored clicks, because a click wrote one condition and a bucket needs two. The pane you click keeps its whole series and marks the buckets inside the range, as every other pane does with its own selection, so a month selected elsewhere also marks the days of that month on a day pane. Clicking the selected bucket clears it; a second bucket replaces the first, and Ctrl or Cmd does not add one, since two ranges on one attribute would return no rows. A line is clicked near a point, not on the exact pixel. Range values are dates for a date column and ISO 8601 with the zone's offset for a timestamp. Ranges a host fixes with `where:` or `default_where` are unchanged (ADR 045, #18).
|
|
13
|
+
|
|
14
|
+
- `as: :doughnut` and `as: :pie`, for a part-to-whole split. `janela_pane Order, :orders, by: :status, as: :doughnut`. A ring is drawn on the server as inline SVG rather than on a canvas, so it is in the page before any JavaScript runs, prints, and has a legend of real buttons that a keyboard and a screen reader can operate; clicking a slice or a legend button filters the other panes exactly as clicking a bar does, and Ctrl or Cmd adds to the selection. A ring cannot show a negative value or a total of nothing, so a pane with either is drawn as a table and says so. Janela publishes a categorical palette to draw them in, `--janela-series-1` to `--janela-series-8` and `--janela-series-other`, in `docs/theming.md`. The engine's own pages draw a ring too, since it needs no chart runtime (ADR 046, #30).
|
|
15
|
+
|
|
16
|
+
- A categorical dimension's filter allows `not_eq` and `not_in`, alongside the existing `eq` and `in`: `Order.janela.query(:revenue, where: { status_not_eq: "refunded" })`. Until now excluding a value could only be phrased as `status_in` naming every other value, which silently stopped covering the dashboard the day a new status value was added; a frame's `default_where` (ADR 043) inherited the same gap, so "this queue never counts a refunded order" had no way to be said that stayed true as the data shape changed. A time dimension gets both for free, the same way it already inherits `not_null`. Additive; nothing that worked before is refused now (ADR 044, #64).
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **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).
|
|
21
|
+
- **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` to your accent; see `UPGRADING.md` (ADR 046).
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- A chart pane no longer inherits the browser's 40px side margins on its `<figure>`. In a narrow column, such as one of four on a page, they left a bar chart a fraction of its tile's width.
|
|
26
|
+
|
|
27
|
+
## [0.10.0] - 2026-09-28
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- `Janela::Frame` gains `default_model` and `default_where`, a permanent filter that is data rather than code: `frame.update!(default_model: "orders", default_where: { status_in: %w[paid pending] })`. Until now the only filter a frame could carry was `where:` (ADR 040), a host's own code, per record, typed into every view that rendered the frame; something true on every render, such as a queue never counting an archived row, had nowhere on the data side to live. `default_model` names the one model the condition is about, so a frame holding panes from more than one model is never narrowed by a condition that was never about the pane reading it: a pane over a different model simply does not receive it, no error. It composes ahead of `where:` and the reader's `q[...]`, applies even on a pane's own dimension, and is validated the moment you save it, against that model's declared dimensions and ADR 025's existing predicate bounds, rather than only discovered wrong at render. Set both columns together; either alone is a validation error. Needs a migration; see `UPGRADING.md` (ADR 043, #63).
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- A bar or line pane's colour now comes from `--janela-accent`, not a literal `rgba(54, 162, 235, ...)`. Three hardcoded copies of Chart.js's own default blue meant a chart disagreed with the very theme installed alongside it, vitral included: a selected table value read the theme's accent, the equivalent bar stayed Chart.js blue. Nothing to configure and nothing new in the theming contract; the property was already public (ADR 016), the chart just never read it (#62).
|
|
36
|
+
|
|
8
37
|
## [0.9.0] - 2026-09-28
|
|
9
38
|
|
|
10
39
|
### Added
|
|
@@ -205,6 +234,8 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
205
234
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
206
235
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
207
236
|
|
|
237
|
+
[0.11.0]: https://github.com/retail-tasker/janela/releases/tag/v0.11.0
|
|
238
|
+
[0.10.0]: https://github.com/retail-tasker/janela/releases/tag/v0.10.0
|
|
208
239
|
[0.9.0]: https://github.com/retail-tasker/janela/releases/tag/v0.9.0
|
|
209
240
|
[0.8.0]: https://github.com/retail-tasker/janela/releases/tag/v0.8.0
|
|
210
241
|
[0.7.0]: https://github.com/retail-tasker/janela/releases/tag/v0.7.0
|
data/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It has t
|
|
|
27
27
|
|
|
28
28
|
```ruby
|
|
29
29
|
# Gemfile
|
|
30
|
-
gem "janela", "~> 0.
|
|
30
|
+
gem "janela", "~> 0.11"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
```ruby
|
|
@@ -127,7 +127,7 @@ class Order < ApplicationRecord
|
|
|
127
127
|
end
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
A dimension with a `granularity` is a time dimension. Groupdate buckets it (`hour`, `day`, `week`, `month`, `quarter`, `year`), fills empty buckets with zero, and uses your app's `Time.zone` and week start. **On SQLite, buckets are UTC**, because SQLite cannot convert time zones: with a non-UTC `Time.zone` a daily bucket is shifted by your offset, and an early-morning row lands in the previous day. Coarser granularities blunt the shift without removing it. If you need local-day buckets on SQLite, store a local date column and use it as a plain dimension.
|
|
130
|
+
A dimension with a `granularity` is a time dimension. Groupdate buckets it (`hour`, `day`, `week`, `month`, `quarter`, `year`), fills empty buckets with zero, and uses your app's `Time.zone` and week start. Clicking a bucket on a time pane, a day on a line or a row of a time table, filters the other panes to the range it covers (`q[placed_on_gteq]=2026-09-01&q[placed_on_lt]=2026-09-02`). The pane clicked keeps its whole series and marks the range, and Ctrl or Cmd does not add a second one, since two ranges cannot be combined (ADR 045). **On SQLite, buckets are UTC**, because SQLite cannot convert time zones: with a non-UTC `Time.zone` a daily bucket is shifted by your offset, and an early-morning row lands in the previous day. Coarser granularities blunt the shift without removing it. If you need local-day buckets on SQLite, store a local date column and use it as a plain dimension.
|
|
131
131
|
|
|
132
132
|
### A subclass inherits
|
|
133
133
|
|
|
@@ -181,7 +181,7 @@ Scope a query to whatever the current user is allowed to see with `on:`:
|
|
|
181
181
|
Order.janela.query(:revenue, by: :status, on: policy_scope(Order))
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
A filter is bound to what kind of dimension it names, not to every predicate Ransack knows (ADR 025). A categorical dimension takes `eq`, `in`, `null` and `not_null
|
|
184
|
+
A filter is bound to what kind of dimension it names, not to every predicate Ransack knows (ADR 025). A categorical dimension takes `eq`, `in`, `not_eq`, `not_in`, `null` and `not_null`, so it can say what it excludes as directly as what it includes (ADR 044); a time dimension additionally takes `gteq`, `gt`, `lteq` and `lt`, so a range still narrows it. Anything else, such as `_cont` or `_matches`, raises `Janela::BadRequest` naming what is allowed. A grouped query with no `limit` gets one anyway, capped at 1000, and a single filter may carry at most 1000 values.
|
|
185
185
|
|
|
186
186
|
Declaring a dimension makes that attribute filterable, so Janela defines the model's Ransack allowlist for you. A model that already defines its own keeps it. A `through:` dimension also needs the **associated** model to allow the attribute, because Ransack's allowlist is per-class:
|
|
187
187
|
|
|
@@ -207,7 +207,9 @@ Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one r
|
|
|
207
207
|
<% end %>
|
|
208
208
|
```
|
|
209
209
|
|
|
210
|
-
A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars. `as:` is `:table` by default, `:bar` for a Chart.js bar chart, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
|
|
210
|
+
A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars. `as:` is `:table` by default, `:bar` for a Chart.js bar chart, `:doughnut` or `:pie` for a part-to-whole split, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
|
|
211
|
+
|
|
212
|
+
A doughnut or a pie is drawn on the server as SVG with a legend of buttons beside it, so it is in the page before any JavaScript runs and can be operated from the keyboard through the legend. Bars, doughnut slices and pie slices are drawn in a palette of eight colours by position, first to eighth, and every value after the eighth in one neutral, so a ring suits a handful of values: `limit: 8` keeps it readable. A ring cannot show a negative value, so a pane with one is drawn as a table and says so. The palette is `--janela-series-1` to `--janela-series-8` and `--janela-series-other` in [docs/theming.md](docs/theming.md) (ADR 046).
|
|
211
213
|
|
|
212
214
|
**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.
|
|
213
215
|
|
|
@@ -286,6 +288,14 @@ What an analyst writes is escaped, never rendered as markup, and a `link` must b
|
|
|
286
288
|
|
|
287
289
|
It travels in each pane's URL as `where[...]`, apart from the reader's `q[...]`, so Clear filters, Escape and a click on the same dimension cannot take it off, and the reader's selection can only narrow inside it. It is bounded like any filter: declared dimensions only, and the predicates ADR 025 allows. It is a view filter, not a permission: it is visible in the pane URL, and a reader who edits it out sees only what your `policy_scope` already allows them to. Keep a record out of reach in the scope, not here. Putting the same filter in the page URL's `q[...]` instead does not hold, because `q[...]` is the reader's to clear (ADR 040).
|
|
288
290
|
|
|
291
|
+
**A frame's own permanent filter**, for something that is true on every render, not one record: "this queue never counts an archived row." Where `where:` is code, per request, this is data, so an analyst changes it without a deploy (ADR 043):
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
frame.update!(default_model: "orders", default_where: { status_in: %w[paid pending] })
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
`default_model` names the one model it narrows; a pane over any other model in the same frame is untouched by it, so a frame is free to hold panes from more than one model without the two ever fighting over what the default means. It composes ahead of `where:` and the reader's `q[...]`, applies even on a pane's own dimension, and is validated the moment you save it, against that model's declared dimensions and ADR 025's predicate bounds, the same sentence a bad request would raise, read at the point you can still fix it. Set both columns together, or neither; either alone is a validation error.
|
|
298
|
+
|
|
289
299
|
### Janela's own pages
|
|
290
300
|
|
|
291
301
|
The engine serves an index and a page per frame at the mount root, so you can install the gem and navigate the same day:
|
|
@@ -534,7 +544,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
|
|
|
534
544
|
|
|
535
545
|
## Status
|
|
536
546
|
|
|
537
|
-
**v0.
|
|
547
|
+
**v0.11.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 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, drill-down on time panes, other chart types. [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).
|
|
538
548
|
|
|
539
549
|
## Development
|
|
540
550
|
|
data/UPGRADING.md
CHANGED
|
@@ -12,6 +12,87 @@ bin/rails janela:doctor
|
|
|
12
12
|
|
|
13
13
|
It reads your application and lists what still needs changing.
|
|
14
14
|
|
|
15
|
+
## 0.10.0 to 0.11.0
|
|
16
|
+
|
|
17
|
+
No migration. Two things you may see.
|
|
18
|
+
|
|
19
|
+
**A reader's range no longer narrows a time pane.**
|
|
20
|
+
|
|
21
|
+
Clicking a bucket on a time pane now writes `placed_on_gteq` and
|
|
22
|
+
`placed_on_lt` (ADR 045), and a time pane, like every other pane, does
|
|
23
|
+
not apply the reader's filters on its own dimension. It shows its whole
|
|
24
|
+
series and marks the buckets inside the range, so you can see where the
|
|
25
|
+
selection sits.
|
|
26
|
+
|
|
27
|
+
That changes one thing: a link or a form that used to send a range in
|
|
28
|
+
`q[...]` to narrow a time pane's own series no longer does. To fix a
|
|
29
|
+
range on a pane, say it in the host's own code, which is unchanged:
|
|
30
|
+
|
|
31
|
+
```erb
|
|
32
|
+
<%= janela_frame @frame, where: { placed_on_gteq: 30.days.ago.to_date.to_s } %>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
or, on a stored frame, `default_where: { placed_on_gteq: "2026-01-01" }`
|
|
36
|
+
with `default_model`. Both still narrow the time pane itself. The doctor
|
|
37
|
+
cannot see this one, because the filter arrives at runtime.
|
|
38
|
+
|
|
39
|
+
**Bars are drawn in a palette now.**
|
|
40
|
+
|
|
41
|
+
Janela publishes a categorical palette (ADR 046), and a bar chart takes
|
|
42
|
+
its colours from it: the first bar in `--janela-accent`, the next seven
|
|
43
|
+
in `--janela-series-2` to `--janela-series-8`, and every bar after the
|
|
44
|
+
eighth in `--janela-series-other`. Until now every bar was the accent.
|
|
45
|
+
Line charts are unchanged.
|
|
46
|
+
|
|
47
|
+
If you want the old look, set the seven to your accent:
|
|
48
|
+
|
|
49
|
+
```css
|
|
50
|
+
:root {
|
|
51
|
+
--janela-series-2: var(--janela-accent);
|
|
52
|
+
--janela-series-3: var(--janela-accent);
|
|
53
|
+
--janela-series-4: var(--janela-accent);
|
|
54
|
+
--janela-series-5: var(--janela-accent);
|
|
55
|
+
--janela-series-6: var(--janela-accent);
|
|
56
|
+
--janela-series-7: var(--janela-accent);
|
|
57
|
+
--janela-series-8: var(--janela-accent);
|
|
58
|
+
--janela-series-other: var(--janela-accent);
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Otherwise there is nothing to do, and the palette is yours to set to
|
|
63
|
+
your own brand's colours. The doctor cannot see this one: what a bar
|
|
64
|
+
looks like is decided in the browser.
|
|
65
|
+
|
|
66
|
+
Nothing else changed: no migration, no renamed identifier, and stored
|
|
67
|
+
frames and existing panes keep working.
|
|
68
|
+
|
|
69
|
+
## 0.9.0 to 0.10.0
|
|
70
|
+
|
|
71
|
+
One migration, if you use stored frames.
|
|
72
|
+
|
|
73
|
+
**Take the frame default filter migration.**
|
|
74
|
+
|
|
75
|
+
A frame can now carry a permanent filter as data (ADR 043).
|
|
76
|
+
`janela_frames` gains `default_model` and `default_where`:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
bin/rails janela:install:migrations
|
|
80
|
+
bin/rails db:migrate
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Every existing frame keeps both columns nil and is unaffected. Nothing
|
|
84
|
+
changes unless you set them:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
frame.update!(default_model: "orders", default_where: { status_in: %w[paid pending] })
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Only a pane over `default_model` takes the filter; every other pane in
|
|
91
|
+
the frame is untouched by it. Set both columns together, or neither:
|
|
92
|
+
either alone is a validation error, and so is a condition your model
|
|
93
|
+
does not declare or a predicate ADR 025 does not allow for that kind of
|
|
94
|
+
dimension.
|
|
95
|
+
|
|
15
96
|
## 0.8.0 to 0.9.0
|
|
16
97
|
|
|
17
98
|
Two migrations, if you use stored frames. Both are taken by the same
|
|
@@ -20,12 +20,17 @@ export default class extends Controller {
|
|
|
20
20
|
label: this.titleValue,
|
|
21
21
|
data: this.valuesValue,
|
|
22
22
|
backgroundColor: this.colours(),
|
|
23
|
-
borderColor: "
|
|
23
|
+
borderColor: this.typeValue === "bar" ? this.colours(0.9) : this.accentColour(0.9),
|
|
24
|
+
...this.pointStyle()
|
|
24
25
|
}]
|
|
25
26
|
},
|
|
26
27
|
options: {
|
|
27
28
|
animation: false,
|
|
28
29
|
scales: { y: { beginAtZero: true } },
|
|
30
|
+
// A line is clicked anywhere along its x position rather than on
|
|
31
|
+
// the exact pixel of a point, which on a dense series is a few
|
|
32
|
+
// pixels wide (ADR 045).
|
|
33
|
+
interaction: this.typeValue === "line" ? { mode: "nearest", axis: "x", intersect: false } : undefined,
|
|
29
34
|
plugins: {
|
|
30
35
|
legend: { display: false },
|
|
31
36
|
// The server formatted every number for the table, so the tooltip
|
|
@@ -36,25 +41,86 @@ export default class extends Controller {
|
|
|
36
41
|
onClick: (event, elements) => {
|
|
37
42
|
if (elements.length === 0) return
|
|
38
43
|
const label = this.labelsValue[elements[0].index]
|
|
39
|
-
const
|
|
44
|
+
const entry = this.filtersValue[String(label)]
|
|
40
45
|
// A custom event carries no modifier flags of its own, so the
|
|
41
46
|
// gesture is read here and passed on (ADR 024).
|
|
42
47
|
const additive = event.native?.ctrlKey === true || event.native?.metaKey === true
|
|
43
|
-
|
|
48
|
+
// A category is one key and value. A time bucket is an object of
|
|
49
|
+
// the conditions for its range, which is never additive (ADR 045).
|
|
50
|
+
if (Array.isArray(entry) && entry[0]) {
|
|
51
|
+
this.dispatch("toggle", { detail: { key: entry[0], value: entry[1], additive } })
|
|
52
|
+
} else if (entry && !Array.isArray(entry)) {
|
|
53
|
+
this.dispatch("toggle", { detail: { filters: entry } })
|
|
54
|
+
}
|
|
44
55
|
}
|
|
45
56
|
}
|
|
46
57
|
})
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
// With nothing selected every bar is solid; with a selection only the
|
|
50
|
-
// selected ones are, and there can be more than one of them.
|
|
51
|
-
|
|
61
|
+
// selected ones are, and there can be more than one of them. A bar takes
|
|
62
|
+
// the colour for its position in the palette (ADR 046); a line is one
|
|
63
|
+
// series and keeps the accent.
|
|
64
|
+
colours(unselected = 0.25) {
|
|
52
65
|
const selected = this.selectedValue.map(String)
|
|
53
|
-
return this.labelsValue.map((label) =>
|
|
54
|
-
selected.length === 0 || selected.includes(String(label))
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
66
|
+
return this.labelsValue.map((label, index) => {
|
|
67
|
+
const solid = selected.length === 0 || selected.includes(String(label))
|
|
68
|
+
const property = this.typeValue === "bar" ? this.seriesProperty(index) : "--janela-accent"
|
|
69
|
+
return this.colour(property, solid ? 0.9 : unselected)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A line shows its selection on its points: the buckets inside the range
|
|
74
|
+
// are solid and larger, the rest faded. A dense series draws no points
|
|
75
|
+
// until one is hovered, so it stays a line (ADR 045).
|
|
76
|
+
pointStyle() {
|
|
77
|
+
if (this.typeValue !== "line") return {}
|
|
78
|
+
|
|
79
|
+
const selected = this.selectedValue.map(String)
|
|
80
|
+
const dense = this.labelsValue.length > 60
|
|
81
|
+
return {
|
|
82
|
+
pointBackgroundColor: this.colours(),
|
|
83
|
+
pointBorderColor: this.colours(),
|
|
84
|
+
pointRadius: this.labelsValue.map((label) => selected.includes(String(label)) ? 5 : (dense ? 0 : 3)),
|
|
85
|
+
pointHoverRadius: 6
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// First to eighth, then the neutral. Never cycled: the ninth bar in the
|
|
90
|
+
// first bar's colour would be two categories drawn the same (ADR 046).
|
|
91
|
+
seriesProperty(index) {
|
|
92
|
+
return index < 8 ? `--janela-series-${index + 1}` : "--janela-series-other"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
accentColour(alpha) {
|
|
96
|
+
return this.colour("--janela-accent", alpha)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// #62: this used to be a literal rgba(54, 162, 235, ...), Chart.js's own
|
|
100
|
+
// default, so a bar disagreed with --janela-accent (ADR 016) and with
|
|
101
|
+
// every other selected thing on the page. Read off this element rather
|
|
102
|
+
// than the document root, so whatever ancestor sets the property is the
|
|
103
|
+
// one honoured, the way it already inherits for everything else.
|
|
104
|
+
colour(property, alpha) {
|
|
105
|
+
const [ r, g, b ] = this.resolved(property).match(/\d+/g)
|
|
106
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A custom property's computed value is returned exactly as authored,
|
|
110
|
+
// "rgb(...)", "#7c3aed", a name, never resolved the way an ordinary
|
|
111
|
+
// colour property is. Setting it as one and reading that back resolves
|
|
112
|
+
// any of them the same way, rather than parsing each form by hand.
|
|
113
|
+
resolved(property) {
|
|
114
|
+
this.resolvedColours ||= {}
|
|
115
|
+
if (this.resolvedColours[property]) return this.resolvedColours[property]
|
|
116
|
+
|
|
117
|
+
const authored = getComputedStyle(this.element).getPropertyValue(property).trim()
|
|
118
|
+
const probe = document.createElement("span")
|
|
119
|
+
probe.style.color = authored
|
|
120
|
+
document.body.appendChild(probe)
|
|
121
|
+
this.resolvedColours[property] = getComputedStyle(probe).color
|
|
122
|
+
probe.remove()
|
|
123
|
+
return this.resolvedColours[property]
|
|
58
124
|
}
|
|
59
125
|
|
|
60
126
|
disconnect() {
|
|
@@ -77,7 +77,9 @@ export default class extends Controller {
|
|
|
77
77
|
// same event carries the same flags when Enter is pressed on a focused
|
|
78
78
|
// value, so the keyboard needs nothing of its own (ADR 024).
|
|
79
79
|
toggle(event) {
|
|
80
|
-
const { key, value } = { ...event.detail, ...event.params }
|
|
80
|
+
const { key, value, filters: range } = { ...event.detail, ...event.params }
|
|
81
|
+
if (range) return this.toggleRange(range)
|
|
82
|
+
|
|
81
83
|
const additive = event.ctrlKey || event.metaKey || event.detail?.additive === true
|
|
82
84
|
const filters = { ...this.filtersValue }
|
|
83
85
|
const selected = this.valuesFor(filters, key).includes(String(value))
|
|
@@ -98,6 +100,22 @@ export default class extends Controller {
|
|
|
98
100
|
this.filtersValue = filters
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
// A time bucket is two conditions, its start and the start of the next
|
|
104
|
+
// bucket, and they are one thing to select or clear. A range is not a set,
|
|
105
|
+
// so a modifier means nothing here: two ranges on one attribute are ANDed
|
|
106
|
+
// and return no rows, which is the failure ADR 024 measured for a value
|
|
107
|
+
// and the null group (ADR 045).
|
|
108
|
+
toggleRange(range) {
|
|
109
|
+
const filters = { ...this.filtersValue }
|
|
110
|
+
const [ first ] = Object.keys(range)
|
|
111
|
+
const selected = Object.entries(range).every(([ key, value ]) => String(filters[key]) === String(value))
|
|
112
|
+
|
|
113
|
+
this.clearDimension(filters, first)
|
|
114
|
+
if (!selected) Object.assign(filters, range)
|
|
115
|
+
|
|
116
|
+
this.filtersValue = filters
|
|
117
|
+
}
|
|
118
|
+
|
|
101
119
|
clear() {
|
|
102
120
|
if (Object.keys(this.filtersValue).length) this.filtersValue = {}
|
|
103
121
|
}
|
|
@@ -141,11 +159,13 @@ export default class extends Controller {
|
|
|
141
159
|
}
|
|
142
160
|
|
|
143
161
|
// Every filter Janela itself writes for the same dimension: the values, the
|
|
144
|
-
// null group, and an _eq that a shared link may still carry.
|
|
145
|
-
// q[...] filters use other predicates and are left alone
|
|
162
|
+
// null group, a time range, and an _eq that a shared link may still carry.
|
|
163
|
+
// A host's own q[...] filters use other predicates and are left alone
|
|
164
|
+
// (ADR 008); a range on a time dimension is the reader's, and a click on
|
|
165
|
+
// that dimension replaces it.
|
|
146
166
|
clearDimension(filters, key) {
|
|
147
|
-
const base = key.replace(/_(in|null|eq)$/, "")
|
|
148
|
-
for (const suffix of [ "in", "null", "eq" ]) delete filters[`${base}_${suffix}`]
|
|
167
|
+
const base = key.replace(/_(in|null|eq|gteq|gt|lteq|lt)$/, "")
|
|
168
|
+
for (const suffix of [ "in", "null", "eq", "gteq", "gt", "lteq", "lt" ]) delete filters[`${base}_${suffix}`]
|
|
149
169
|
}
|
|
150
170
|
|
|
151
171
|
// Stimulus calls this as the controller starts, with the filters the server
|
|
@@ -12,6 +12,20 @@
|
|
|
12
12
|
--janela-space: 0.25rem;
|
|
13
13
|
--janela-line: rgba(128, 128, 128, 0.3);
|
|
14
14
|
--janela-accent: rgb(54, 162, 235);
|
|
15
|
+
|
|
16
|
+
/* The categorical palette (ADR 046): a bar, a doughnut slice or a pie
|
|
17
|
+
slice is drawn in the colour for its position, first to eighth, and
|
|
18
|
+
everything after the eighth in the neutral. Never cycled. The first
|
|
19
|
+
follows the accent, so a host's own colour still leads. */
|
|
20
|
+
--janela-series-1: var(--janela-accent);
|
|
21
|
+
--janela-series-2: #eb6834;
|
|
22
|
+
--janela-series-3: #1baf7a;
|
|
23
|
+
--janela-series-4: #eda100;
|
|
24
|
+
--janela-series-5: #e87ba4;
|
|
25
|
+
--janela-series-6: #008300;
|
|
26
|
+
--janela-series-7: #4a3aa7;
|
|
27
|
+
--janela-series-8: #e34948;
|
|
28
|
+
--janela-series-other: #8c8c8c;
|
|
15
29
|
}
|
|
16
30
|
|
|
17
31
|
.janela-frame { display: grid; }
|
|
@@ -94,6 +108,33 @@ table.janela-pane button[aria-pressed="true"] { background: var(--janela-accent)
|
|
|
94
108
|
.janela-chart-title { font-weight: 600; margin-bottom: calc(var(--janela-space) * 2); }
|
|
95
109
|
canvas.janela-chart { width: 100% !important; max-height: 20rem; }
|
|
96
110
|
|
|
111
|
+
/* A chart pane is a <figure>, and a browser gives a figure 40px of margin
|
|
112
|
+
either side. Harmless in a wide column and most of a narrow one. */
|
|
113
|
+
figure.janela-pane { margin: 0; }
|
|
114
|
+
|
|
115
|
+
/* A doughnut or a pie is inline SVG with a legend beside it (ADR 046). The
|
|
116
|
+
gap between slices is drawn in the page's own colour so no boundary
|
|
117
|
+
depends on hue alone. */
|
|
118
|
+
.janela-ring-svg { display: block; width: 100%; max-width: 16rem; max-height: 16rem; margin: 0 auto calc(var(--janela-space) * 3); }
|
|
119
|
+
.janela-ring-slice { fill-rule: evenodd; stroke: Canvas; stroke-width: 0.8; }
|
|
120
|
+
.janela-ring-slice[data-action] { cursor: pointer; }
|
|
121
|
+
.janela-ring-slice.janela-dim { opacity: 0.25; }
|
|
122
|
+
table.janela-legend { width: 100%; border-collapse: collapse; }
|
|
123
|
+
table.janela-legend td { padding: calc(var(--janela-space) * 1.5) 0; border-top: 1px solid var(--janela-line); }
|
|
124
|
+
table.janela-legend td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
|
125
|
+
table.janela-legend button {
|
|
126
|
+
font: inherit;
|
|
127
|
+
color: inherit;
|
|
128
|
+
background: none;
|
|
129
|
+
border: 0;
|
|
130
|
+
padding: calc(var(--janela-space) * 0.5) calc(var(--janela-space) * 2);
|
|
131
|
+
border-radius: 999px;
|
|
132
|
+
cursor: pointer;
|
|
133
|
+
}
|
|
134
|
+
table.janela-legend button:hover { background: var(--janela-line); }
|
|
135
|
+
table.janela-legend button[aria-pressed="true"] { background: var(--janela-accent); color: white; }
|
|
136
|
+
.janela-swatch { display: inline-block; width: 0.75rem; height: 0.75rem; border-radius: 2px; vertical-align: middle; }
|
|
137
|
+
|
|
97
138
|
/* A host whose own markup already says what a pane is puts this on any
|
|
98
139
|
ancestor, and the caption, the value's label and the chart's title stop
|
|
99
140
|
being drawn without leaving the accessibility tree. Hidden rather than
|
data/app/models/janela/frame.rb
CHANGED
|
@@ -18,6 +18,9 @@ module Janela
|
|
|
18
18
|
# The shape of the symbol a host passes, so nothing that reads like a
|
|
19
19
|
# name or a path gets in (ADR 041).
|
|
20
20
|
validates :key, format: { with: /\A[a-z0-9_]+\z/ }, uniqueness: { scope: %i[owner_type owner_id] }, allow_nil: true
|
|
21
|
+
# Present or absent together, and default_where is only ever as valid as
|
|
22
|
+
# default_model says it can be (ADR 043).
|
|
23
|
+
validate :default_where_matches_default_model
|
|
21
24
|
|
|
22
25
|
# The host's frame for this owner and key, created on first use (ADR 041).
|
|
23
26
|
# The block runs only when the frame is created, to set what a new frame
|
|
@@ -44,5 +47,31 @@ module Janela
|
|
|
44
47
|
pane.update_columns(position: index + 1) unless pane.position == index + 1
|
|
45
48
|
end
|
|
46
49
|
end
|
|
50
|
+
|
|
51
|
+
# This frame's permanent filter, if the pane asking is over the model it
|
|
52
|
+
# names; empty for any other model, because the condition was never
|
|
53
|
+
# claimed to be about it (ADR 043).
|
|
54
|
+
def default_for(model)
|
|
55
|
+
return {} if default_model.blank? || default_model != model.model_name.route_key
|
|
56
|
+
|
|
57
|
+
default_where.to_h
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
def default_where_matches_default_model
|
|
62
|
+
return if default_model.blank? && default_where.blank?
|
|
63
|
+
return errors.add(:default_where, "cannot be set without default_model") if default_model.blank?
|
|
64
|
+
return errors.add(:default_model, "cannot be set without default_where") if default_where.blank?
|
|
65
|
+
|
|
66
|
+
definition = Janela.definition!(default_model)
|
|
67
|
+
# A no-op relation: this checks the condition's shape against the
|
|
68
|
+
# model's declared dimensions (ADR 025), and never has to touch a row
|
|
69
|
+
# to do it.
|
|
70
|
+
definition.narrow(definition.model.none, default_where)
|
|
71
|
+
rescue Janela::NotFound
|
|
72
|
+
errors.add(:default_model, "must be a model with a janela block")
|
|
73
|
+
rescue Janela::BadRequest => e
|
|
74
|
+
errors.add(:default_where, e.message)
|
|
75
|
+
end
|
|
47
76
|
end
|
|
48
77
|
end
|
data/app/models/janela/pane.rb
CHANGED
|
@@ -73,7 +73,7 @@ module Janela
|
|
|
73
73
|
def query(filters: {}, fixed: {}, renderer: self.renderer)
|
|
74
74
|
Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
|
|
75
75
|
renderer: renderer, granularity: granularity, limit: limit, filters: filters, fixed: fixed,
|
|
76
|
-
title: title)
|
|
76
|
+
default: frame.default_for(definition.model), title: title)
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
# The row's own words, for a list or a heading. Built from the columns
|
|
@@ -99,7 +99,7 @@ module Janela
|
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
def chart?
|
|
102
|
-
Query::
|
|
102
|
+
Query::CANVAS.include?(renderer.to_s)
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
def definition
|