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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1dbbea1c971d06f91daeb2e1bbd3ecc0a9d056363efd0e61664bf9cac51da9b
4
- data.tar.gz: 50e76f042113f5e5e071f20d242c62176672aba367ab676448f6e722f1df9f2d
3
+ metadata.gz: 2079a652eafc71edc8c31227d2c09ef8e795a38da68b8ec6c18de89732a54f97
4
+ data.tar.gz: b024b3337cd75f63619ff8cb844a6887e2b4ecb0bf7cb510ceb2fea789537b03
5
5
  SHA512:
6
- metadata.gz: 2a96c9be9933a2c7f4e33109bd5bd63f8fe6a0003047e66a3ab28f306bf33aa5d8be3f8be860ce306d4a994775749dcd410ca64998d8c856916caa048e3ab1c2
7
- data.tar.gz: b9d548a0042e569e3cd9ceaae714af366b852a1a81ec95966bbd0a1b7b21f1978d352f560192fad01b87acc14d65d0dddb652ec443702eda5ffabeb61507d32f
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.9"
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
 
@@ -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.9.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 frame filter no click can remove, 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).
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: "rgba(54, 162, 235, 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,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 [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
- ? "rgba(54, 162, 235, 0.9)"
56
- : "rgba(54, 162, 235, 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)
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. 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
@@ -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
@@ -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::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