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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eac851a1f9a4eda7cac89f9ceced6664b28b26a5c889b1939147a0f5a4ed5688
4
- data.tar.gz: 5425fbb11f1585892bbc4cff9b87ddbf9f6c08a2398ec87d5cd9402e8d624d51
3
+ metadata.gz: 2079a652eafc71edc8c31227d2c09ef8e795a38da68b8ec6c18de89732a54f97
4
+ data.tar.gz: b024b3337cd75f63619ff8cb844a6887e2b4ecb0bf7cb510ceb2fea789537b03
5
5
  SHA512:
6
- metadata.gz: 3d8563226d41987ac12140bc9af25a6492f1784937d02796c077e1282d999c132e0e19285d9f938a78b4d20e98958f28e2bd1bb55a14a6bad23e8267b5bf9a9b
7
- data.tar.gz: 0a836386550bc34bcb4e16d284592f7a8473accdbf073385eadc88e3f6b2e1da9eb14ea61394a0d0b79baa59f3f9c6e540b7f3423d6064b23da21b24f4bdc6c7
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.10"
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`; 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.
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.10.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar and line 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).
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 [key, value] = this.filtersValue[String(label)] || []
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
- if (key) this.dispatch("toggle", { detail: { key, value, additive } })
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
- colours() {
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
- ? this.accentColour(0.9)
56
- : this.accentColour(0.25)
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
- accentColour(alpha) {
66
- const [ r, g, b ] = this.resolvedAccent().match(/\d+/g)
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
- resolvedAccent() {
75
- if (this.resolvedAccentValue) return this.resolvedAccentValue
113
+ resolved(property) {
114
+ this.resolvedColours ||= {}
115
+ if (this.resolvedColours[property]) return this.resolvedColours[property]
76
116
 
77
- const accent = getComputedStyle(this.element).getPropertyValue("--janela-accent").trim()
117
+ const authored = getComputedStyle(this.element).getPropertyValue(property).trim()
78
118
  const probe = document.createElement("span")
79
- probe.style.color = accent
119
+ probe.style.color = authored
80
120
  document.body.appendChild(probe)
81
- this.resolvedAccentValue = getComputedStyle(probe).color
121
+ this.resolvedColours[property] = getComputedStyle(probe).color
82
122
  probe.remove()
83
- return this.resolvedAccentValue
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. A host's own
145
- // q[...] filters use other predicates and are left alone (ADR 008).
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
@@ -99,7 +99,7 @@ module Janela
99
99
  end
100
100
 
101
101
  def chart?
102
- Query::RENDERERS.include?(renderer.to_s) && renderer.to_s != "table"
102
+ Query::CANVAS.include?(renderer.to_s)
103
103
  end
104
104
 
105
105
  def definition
@@ -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? && !time? && !frozen?
78
+ !single_value? && !frozen?
68
79
  end
69
80
 
70
81
  def chart?
71
- !single_value? && renderer != "table"
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? || time?
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
- <table class="janela-pane">
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
- <% key, value = query.filter_params(label) %>
34
+ <% click = query.click_data(label) if query.clickable? %>
32
35
  <tr>
33
36
  <td>
34
- <% if key %>
35
- <button type="button"
36
- aria-pressed="<%= query.selected?(label) %>"
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
- </table>
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 %>