janela 0.10.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 +20 -0
- data/README.md +7 -5
- data/UPGRADING.md +54 -0
- data/app/assets/javascripts/janela/chart_controller.js +58 -18
- data/app/assets/javascripts/janela/frame_controller.js +25 -5
- data/app/assets/stylesheets/janela.css +41 -0
- data/app/models/janela/pane.rb +1 -1
- data/app/models/janela/query.rb +136 -7
- 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/docs/decisions/006-time-dimensions-with-groupdate.md +1 -1
- 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 +14 -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 +14 -8
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,25 @@ 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
|
+
|
|
8
27
|
## [0.10.0] - 2026-09-28
|
|
9
28
|
|
|
10
29
|
### Added
|
|
@@ -215,6 +234,7 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
215
234
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
216
235
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
217
236
|
|
|
237
|
+
[0.11.0]: https://github.com/retail-tasker/janela/releases/tag/v0.11.0
|
|
218
238
|
[0.10.0]: https://github.com/retail-tasker/janela/releases/tag/v0.10.0
|
|
219
239
|
[0.9.0]: https://github.com/retail-tasker/janela/releases/tag/v0.9.0
|
|
220
240
|
[0.8.0]: https://github.com/retail-tasker/janela/releases/tag/v0.8.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
|
|
|
@@ -542,7 +544,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
|
|
|
542
544
|
|
|
543
545
|
## Status
|
|
544
546
|
|
|
545
|
-
**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).
|
|
546
548
|
|
|
547
549
|
## Development
|
|
548
550
|
|
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.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
|
+
|
|
15
69
|
## 0.9.0 to 0.10.0
|
|
16
70
|
|
|
17
71
|
One migration, if you use stored frames.
|
|
@@ -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: this.accentColour(0.9)
|
|
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,59 @@ 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)
|
|
58
97
|
}
|
|
59
98
|
|
|
60
99
|
// #62: this used to be a literal rgba(54, 162, 235, ...), Chart.js's own
|
|
@@ -62,8 +101,8 @@ export default class extends Controller {
|
|
|
62
101
|
// every other selected thing on the page. Read off this element rather
|
|
63
102
|
// than the document root, so whatever ancestor sets the property is the
|
|
64
103
|
// one honoured, the way it already inherits for everything else.
|
|
65
|
-
|
|
66
|
-
const [ r, g, b ] = this.
|
|
104
|
+
colour(property, alpha) {
|
|
105
|
+
const [ r, g, b ] = this.resolved(property).match(/\d+/g)
|
|
67
106
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
68
107
|
}
|
|
69
108
|
|
|
@@ -71,16 +110,17 @@ export default class extends Controller {
|
|
|
71
110
|
// "rgb(...)", "#7c3aed", a name, never resolved the way an ordinary
|
|
72
111
|
// colour property is. Setting it as one and reading that back resolves
|
|
73
112
|
// any of them the same way, rather than parsing each form by hand.
|
|
74
|
-
|
|
75
|
-
|
|
113
|
+
resolved(property) {
|
|
114
|
+
this.resolvedColours ||= {}
|
|
115
|
+
if (this.resolvedColours[property]) return this.resolvedColours[property]
|
|
76
116
|
|
|
77
|
-
const
|
|
117
|
+
const authored = getComputedStyle(this.element).getPropertyValue(property).trim()
|
|
78
118
|
const probe = document.createElement("span")
|
|
79
|
-
probe.style.color =
|
|
119
|
+
probe.style.color = authored
|
|
80
120
|
document.body.appendChild(probe)
|
|
81
|
-
this.
|
|
121
|
+
this.resolvedColours[property] = getComputedStyle(probe).color
|
|
82
122
|
probe.remove()
|
|
83
|
-
return this.
|
|
123
|
+
return this.resolvedColours[property]
|
|
84
124
|
}
|
|
85
125
|
|
|
86
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/pane.rb
CHANGED
data/app/models/janela/query.rb
CHANGED
|
@@ -5,7 +5,18 @@ module Janela
|
|
|
5
5
|
# rather than collapsing this one to the value clicked. A query read from a
|
|
6
6
|
# snapshot shows stored results and cannot be clicked at all.
|
|
7
7
|
class Query
|
|
8
|
-
RENDERERS = %w[table bar line].freeze
|
|
8
|
+
RENDERERS = %w[table bar line doughnut pie].freeze
|
|
9
|
+
|
|
10
|
+
# Drawn by Chart.js on a canvas, and so blank without a chart runtime.
|
|
11
|
+
CANVAS = %w[bar line].freeze
|
|
12
|
+
|
|
13
|
+
# Drawn on the server as SVG instead (ADR 046).
|
|
14
|
+
RINGS = %w[doughnut pie].freeze
|
|
15
|
+
|
|
16
|
+
# Palette slots before the neutral. Colour is chosen by position and never
|
|
17
|
+
# cycled: a ninth category drawn in the first colour would be two
|
|
18
|
+
# categories the same beside a legend that says they differ (ADR 046).
|
|
19
|
+
SERIES = 8
|
|
9
20
|
|
|
10
21
|
attr_reader :definition, :measure, :dimension, :renderer, :limit, :filters, :fixed, :default, :snapshot
|
|
11
22
|
|
|
@@ -60,15 +71,87 @@ module Janela
|
|
|
60
71
|
@granularity || (dimension_definition.granularity if time?)
|
|
61
72
|
end
|
|
62
73
|
|
|
63
|
-
# Clicking a category adds one Ransack condition; clicking a time bucket
|
|
64
|
-
# would need two, and the dashboard toggles one key at a time (ADR 006).
|
|
65
74
|
# A stored pane is the record of a moment and is not clickable (ADR 009).
|
|
75
|
+
# A time pane is: a bucket writes the pair of conditions for its range
|
|
76
|
+
# (ADR 045).
|
|
66
77
|
def clickable?
|
|
67
|
-
!single_value? && !
|
|
78
|
+
!single_value? && !frozen?
|
|
68
79
|
end
|
|
69
80
|
|
|
70
81
|
def chart?
|
|
71
|
-
!single_value? && renderer
|
|
82
|
+
!single_value? && CANVAS.include?(renderer)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ring?
|
|
86
|
+
!single_value? && RINGS.include?(renderer)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A part of a whole cannot be negative, and a ring of nothing draws
|
|
90
|
+
# nothing. Either way the pane is a table that says why, not a wrong
|
|
91
|
+
# picture (ADR 046).
|
|
92
|
+
def hole?
|
|
93
|
+
renderer == "doughnut"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def ringable?(result)
|
|
97
|
+
values = result.values.map(&:to_f)
|
|
98
|
+
values.none?(&:negative?) && values.sum.positive?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The custom property a category at this position is drawn with.
|
|
102
|
+
def series_property(index)
|
|
103
|
+
index < SERIES ? "--janela-series-#{index + 1}" : "--janela-series-other"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# One entry per label, in result order, with the arc it covers. A zero is
|
|
107
|
+
# kept for the legend and has no arc. The filter is the one a click on
|
|
108
|
+
# that label toggles, so the slice and its legend row cannot disagree.
|
|
109
|
+
CENTRE = 50.0
|
|
110
|
+
OUTER = 48.0
|
|
111
|
+
INNER = 27.0
|
|
112
|
+
|
|
113
|
+
Slice = Struct.new(:label, :formatted, :property, :click, :selected, :fraction, :from, keyword_init: true) do
|
|
114
|
+
# The SVG path of this slice on a 100 by 100 canvas, from twelve o'clock
|
|
115
|
+
# clockwise, or nil when it covers nothing. One slice covering the whole
|
|
116
|
+
# circle is two half arcs, since an arc from a point to itself is not
|
|
117
|
+
# drawn. A doughnut's hole is a second sub-path, cut out by the
|
|
118
|
+
# stylesheet's even-odd fill rule.
|
|
119
|
+
def path(hole:)
|
|
120
|
+
return unless fraction.positive?
|
|
121
|
+
return whole(hole) if fraction >= 0.9999
|
|
122
|
+
|
|
123
|
+
large = fraction > 0.5 ? 1 : 0
|
|
124
|
+
outer_from, outer_to = point(OUTER, from), point(OUTER, from + fraction)
|
|
125
|
+
if hole
|
|
126
|
+
inner_from, inner_to = point(INNER, from), point(INNER, from + fraction)
|
|
127
|
+
"M #{outer_from} A #{OUTER} #{OUTER} 0 #{large} 1 #{outer_to} L #{inner_to} A #{INNER} #{INNER} 0 #{large} 0 #{inner_from} Z"
|
|
128
|
+
else
|
|
129
|
+
"M #{CENTRE} #{CENTRE} L #{outer_from} A #{OUTER} #{OUTER} 0 #{large} 1 #{outer_to} Z"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
def whole(hole)
|
|
135
|
+
circle = ->(radius) { "M #{CENTRE} #{CENTRE - radius} A #{radius} #{radius} 0 1 1 #{CENTRE} #{CENTRE + radius} A #{radius} #{radius} 0 1 1 #{CENTRE} #{CENTRE - radius} Z" }
|
|
136
|
+
hole ? "#{circle.(OUTER)} #{circle.(INNER)}" : circle.(OUTER)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def point(radius, turns)
|
|
140
|
+
angle = turns * 2 * Math::PI - Math::PI / 2
|
|
141
|
+
format("%.3f %.3f", CENTRE + radius * Math.cos(angle), CENTRE + radius * Math.sin(angle))
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def slices(result)
|
|
146
|
+
total = result.values.sum(&:to_f)
|
|
147
|
+
from = 0.0
|
|
148
|
+
result.each_with_index.map do |(label, measured), index|
|
|
149
|
+
fraction = measured.to_f / total
|
|
150
|
+
slice = Slice.new(label: label.to_s, formatted: format(measured), property: series_property(index),
|
|
151
|
+
click: (click_data(label) if clickable?), selected: selected?(label), fraction: fraction, from: from)
|
|
152
|
+
from += fraction
|
|
153
|
+
slice
|
|
154
|
+
end
|
|
72
155
|
end
|
|
73
156
|
|
|
74
157
|
def turbo_frame_id
|
|
@@ -105,9 +188,29 @@ module Janela
|
|
|
105
188
|
# selection, which this pane shows the alternatives to (ADR 040, 043).
|
|
106
189
|
on = definition.narrow(on || model.all, default) if default.present?
|
|
107
190
|
on = definition.narrow(on || model.all, fixed) if fixed.present?
|
|
191
|
+
return time_result(on) if time?
|
|
192
|
+
|
|
108
193
|
definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
|
|
109
194
|
end
|
|
110
195
|
|
|
196
|
+
# The two filters a click on this label writes, for a time pane: the start
|
|
197
|
+
# of its bucket and the start of the next (ADR 045).
|
|
198
|
+
def range_for(label)
|
|
199
|
+
from, to = dimension_definition.bounds(@buckets.fetch(label.to_s), granularity)
|
|
200
|
+
{ "#{ransack_name}_gteq" => from, "#{ransack_name}_lt" => to }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The data attributes a table button or legend row carries: one key and
|
|
204
|
+
# value for a category, the pair of conditions for a bucket.
|
|
205
|
+
def click_data(label)
|
|
206
|
+
if time?
|
|
207
|
+
{ janela__frame_filters_param: range_for(label).to_json }
|
|
208
|
+
else
|
|
209
|
+
key, value = filter_params(label)
|
|
210
|
+
key ? { janela__frame_key_param: key, janela__frame_value_param: value } : nil
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
111
214
|
# The Ransack key and value a click on this label should toggle. A null
|
|
112
215
|
# group filters with the null predicate, not an empty string (ADR 009 has
|
|
113
216
|
# no say here; see issue #21).
|
|
@@ -116,7 +219,7 @@ module Janela
|
|
|
116
219
|
# _eq link is still read, since a URL somebody already sent should not stop
|
|
117
220
|
# working to suit us (ADR 024).
|
|
118
221
|
def filter_params(label)
|
|
119
|
-
return [ nil, nil ] unless clickable?
|
|
222
|
+
return [ nil, nil ] unless clickable? && !time?
|
|
120
223
|
return [ "#{ransack_name}_null", "1" ] if label.to_s == Dimension::NONE
|
|
121
224
|
|
|
122
225
|
[ "#{ransack_name}_in", label.to_s ]
|
|
@@ -126,6 +229,7 @@ module Janela
|
|
|
126
229
|
# to look up by label when a bar is clicked.
|
|
127
230
|
def filters_for(labels)
|
|
128
231
|
return {} unless clickable?
|
|
232
|
+
return labels.to_h { |label| [ label.to_s, range_for(label) ] } if time?
|
|
129
233
|
|
|
130
234
|
labels.to_h { |label| [ label.to_s, filter_params(label) ] }
|
|
131
235
|
end
|
|
@@ -137,6 +241,7 @@ module Janela
|
|
|
137
241
|
# like any other label.
|
|
138
242
|
def selected_values
|
|
139
243
|
return [] unless clickable?
|
|
244
|
+
return selected_buckets if time?
|
|
140
245
|
|
|
141
246
|
values = Array(filter("#{ransack_name}_in")) + Array(filter("#{ransack_name}_eq"))
|
|
142
247
|
values = values.map(&:to_s)
|
|
@@ -161,8 +266,32 @@ module Janela
|
|
|
161
266
|
dimension_definition.ransack_name
|
|
162
267
|
end
|
|
163
268
|
|
|
269
|
+
# A time pane's series is read as buckets, not labels, and the buckets
|
|
270
|
+
# are kept: the labels alone cannot say what range a click covers.
|
|
271
|
+
def time_result(on)
|
|
272
|
+
series = definition.series(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity)
|
|
273
|
+
@buckets = series.keys.to_h { |bucket| [ dimension_definition.label(bucket, granularity), bucket ] }
|
|
274
|
+
series.transform_keys { |bucket| dimension_definition.label(bucket, granularity) }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The buckets that lie wholly inside the range the filters name. Janela
|
|
278
|
+
# writes both ends, so a range with one is somebody else's and selects
|
|
279
|
+
# nothing here. Read before the first result there are no buckets yet.
|
|
280
|
+
def selected_buckets
|
|
281
|
+
from, to = filter("#{ransack_name}_gteq"), filter("#{ransack_name}_lt")
|
|
282
|
+
return [] if from.blank? || to.blank? || @buckets.nil?
|
|
283
|
+
|
|
284
|
+
from, to = Time.zone.parse(from.to_s), Time.zone.parse(to.to_s)
|
|
285
|
+
return [] if from.nil? || to.nil?
|
|
286
|
+
|
|
287
|
+
@buckets.select { |_, bucket|
|
|
288
|
+
start, finish = dimension_definition.span(bucket, granularity)
|
|
289
|
+
start >= from && finish <= to
|
|
290
|
+
}.keys
|
|
291
|
+
end
|
|
292
|
+
|
|
164
293
|
def applicable_filters
|
|
165
|
-
return filters if single_value?
|
|
294
|
+
return filters if single_value?
|
|
166
295
|
|
|
167
296
|
filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
|
|
168
297
|
end
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
</p>
|
|
8
8
|
<% elsif result.empty? %>
|
|
9
9
|
<p class="janela-pane janela-empty"><%= query.title %>: no data</p>
|
|
10
|
+
<% elsif query.ring? && query.ringable?(result) %>
|
|
11
|
+
<%= render "janela/queries/ring", query: query, result: result %>
|
|
10
12
|
<% elsif query.chart? %>
|
|
11
13
|
<% title_id = "#{query.turbo_frame_id}-title" %>
|
|
12
14
|
<figure class="janela-pane">
|
|
@@ -21,24 +23,20 @@
|
|
|
21
23
|
data-janela--chart-values-value="<%= result.values.map(&:to_f).to_json %>"
|
|
22
24
|
data-janela--chart-formatted-value="<%= result.values.map { |measured| query.format(measured) }.to_json %>"
|
|
23
25
|
data-janela--chart-filters-value="<%= query.filters_for(result.keys).to_json %>"
|
|
26
|
+
<%= tag.attributes(aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) }) %>
|
|
24
27
|
role="img" aria-labelledby="<%= title_id %>"></canvas>
|
|
25
28
|
</figure>
|
|
26
29
|
<% else %>
|
|
27
|
-
|
|
30
|
+
<%= tag.table class: "janela-pane", aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) } do %>
|
|
28
31
|
<caption><%= query.title %></caption>
|
|
29
32
|
<tbody>
|
|
30
33
|
<% result.each do |label, measured| %>
|
|
31
|
-
<%
|
|
34
|
+
<% click = query.click_data(label) if query.clickable? %>
|
|
32
35
|
<tr>
|
|
33
36
|
<td>
|
|
34
|
-
<% if
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
data-action="janela--frame#toggle"
|
|
38
|
-
data-janela--frame-key-param="<%= key %>"
|
|
39
|
-
data-janela--frame-value-param="<%= value %>">
|
|
40
|
-
<%= label %>
|
|
41
|
-
</button>
|
|
37
|
+
<% if click %>
|
|
38
|
+
<%= tag.button label, type: "button", aria: { pressed: query.selected?(label) },
|
|
39
|
+
data: { action: "janela--frame#toggle" }.merge(click) %>
|
|
42
40
|
<% else %>
|
|
43
41
|
<span><%= label %></span>
|
|
44
42
|
<% end %>
|
|
@@ -47,5 +45,10 @@
|
|
|
47
45
|
</tr>
|
|
48
46
|
<% end %>
|
|
49
47
|
</tbody>
|
|
50
|
-
|
|
48
|
+
<% end %>
|
|
49
|
+
<%# A ring cannot draw a negative part or a total of nothing, so it says it
|
|
50
|
+
is a table rather than drawing something wrong (ADR 046). %>
|
|
51
|
+
<% if query.ring? %>
|
|
52
|
+
<p class="janela-muted"><%= t("janela.rings.not_drawable") %></p>
|
|
53
|
+
<% end %>
|
|
51
54
|
<% end %>
|