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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<%# A doughnut or a pie, drawn here rather than on a canvas so it is in the
|
|
2
|
+
HTML before any JavaScript runs, prints, and can be read and operated by
|
|
3
|
+
a keyboard through the legend (ADR 046, ADR 026). A slice and its legend
|
|
4
|
+
row toggle the same filter, and the legend is the control a keyboard and a
|
|
5
|
+
screen reader use, so the slices are not offered to them twice. %>
|
|
6
|
+
<% title_id = "#{query.turbo_frame_id}-title" %>
|
|
7
|
+
<% slices = query.slices(result) %>
|
|
8
|
+
<% selecting = slices.any?(&:selected) %>
|
|
9
|
+
<figure class="janela-pane janela-ring">
|
|
10
|
+
<figcaption class="janela-chart-title" id="<%= title_id %>"><%= query.title %></figcaption>
|
|
11
|
+
<svg class="janela-ring-svg" viewBox="0 0 100 100" role="img" aria-labelledby="<%= title_id %>"
|
|
12
|
+
data-hole="<%= query.hole? %>">
|
|
13
|
+
<% slices.each do |slice| %>
|
|
14
|
+
<% path = slice.path(hole: query.hole?) %>
|
|
15
|
+
<% next unless path %>
|
|
16
|
+
<%= tag.path d: path, class: [ "janela-ring-slice", ("janela-dim" if selecting && !slice.selected) ],
|
|
17
|
+
style: "fill: var(#{slice.property})",
|
|
18
|
+
data: (slice.click ? { action: "click->janela--frame#toggle" }.merge(slice.click) : {}) do %>
|
|
19
|
+
<title><%= slice.label %>: <%= slice.formatted %></title>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</svg>
|
|
23
|
+
<table class="janela-legend">
|
|
24
|
+
<tbody>
|
|
25
|
+
<% slices.each do |slice| %>
|
|
26
|
+
<tr>
|
|
27
|
+
<td>
|
|
28
|
+
<span class="janela-swatch" style="background: var(<%= slice.property %>)" aria-hidden="true"></span>
|
|
29
|
+
<% if slice.click %>
|
|
30
|
+
<%= tag.button slice.label, type: "button", aria: { pressed: slice.selected },
|
|
31
|
+
data: { action: "janela--frame#toggle" }.merge(slice.click) %>
|
|
32
|
+
<% else %>
|
|
33
|
+
<span><%= slice.label %></span>
|
|
34
|
+
<% end %>
|
|
35
|
+
</td>
|
|
36
|
+
<td><%= slice.formatted %></td>
|
|
37
|
+
</tr>
|
|
38
|
+
<% end %>
|
|
39
|
+
</tbody>
|
|
40
|
+
</table>
|
|
41
|
+
</figure>
|
data/config/locales/en.yml
CHANGED
|
@@ -66,5 +66,11 @@ en:
|
|
|
66
66
|
title_placeholder: Janela writes one if you leave this blank
|
|
67
67
|
renderers:
|
|
68
68
|
bar: Bar chart
|
|
69
|
+
doughnut: Doughnut chart
|
|
69
70
|
line: Line chart
|
|
71
|
+
pie: Pie chart
|
|
70
72
|
table: Table
|
|
73
|
+
time:
|
|
74
|
+
click_hint: Choose a period to filter the other panes to it. Ctrl or Cmd does not add a second period.
|
|
75
|
+
rings:
|
|
76
|
+
not_drawable: A ring cannot show a negative value or a total of nothing, so this is drawn as a table.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-29
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 024, ADR 025, ADR 028, ADR 040, ADR 043
|
|
5
|
+
Triggers:
|
|
6
|
+
- adding not_eq or not_in to a dimension's allowed predicates
|
|
7
|
+
- a frame's default filter, a host's fixed filter or a reader's q[...]
|
|
8
|
+
needing to say "not this value" rather than listing every other one
|
|
9
|
+
- widening CATEGORICAL_PREDICATES or TIME_PREDICATES
|
|
10
|
+
- reasoning about which dashboard question justifies a new predicate
|
|
11
|
+
Topics: security, ransack, dsl, frames, cross-filtering
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 044: A Categorical Dimension Can Say What It Excludes, Not Only What It Includes
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
#64: a categorical dimension's allowed predicates (ADR 025, corrected by
|
|
19
|
+
ADR 028) are `eq`, `in`, `null` and `not_null`. There is no way to say
|
|
20
|
+
"not this one." Reproduced against the demo:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
Order.janela.narrow(Order.all, "status_not_eq" => "refunded")
|
|
24
|
+
# => Janela::BadRequest: Order does not allow status_not_eq. This
|
|
25
|
+
# dimension allows status_eq, status_in, status_null, status_not_null.
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`Definition#narrow`/`#filter` is the single choke point every filter
|
|
29
|
+
source goes through (ADR 025), so a reader's `q[...]`, a host's `where:`
|
|
30
|
+
(ADR 040) and a frame's `default_where` (ADR 043) all hit the same wall.
|
|
31
|
+
|
|
32
|
+
**The question is not whether the list is wrong, the way ADR 028 found
|
|
33
|
+
it.** ADR 028 restored `not_null` because it was already shipped, tested
|
|
34
|
+
behaviour that a first-principles list had missed. `not_eq`/`not_in` are
|
|
35
|
+
not already shipped anywhere; nothing measured today shows them working.
|
|
36
|
+
This is a request for new capability, and ADR 028 already set the bar for
|
|
37
|
+
that: *"a predicate belongs there if a dashboard produces it, or if it is
|
|
38
|
+
a boolean test of presence rather than a way to phrase a match."*
|
|
39
|
+
`not_eq`/`not_in` fail both readings as ADR 028 wrote them: ADR 024 says
|
|
40
|
+
a click only ever writes `eq`/`in`, and excluding a value is not a
|
|
41
|
+
presence test the way `not_null` is.
|
|
42
|
+
|
|
43
|
+
**But a dashboard question that needs it already exists, and it is the
|
|
44
|
+
one that motivated #63.** ADR 043 built a frame's permanent
|
|
45
|
+
`default_where` for exactly this sentence: "this queue never counts an
|
|
46
|
+
archived row." That sentence can be typed today only as `status_in` with
|
|
47
|
+
every value except `archived`, and #64's own point stands: that
|
|
48
|
+
inclusion list silently stops covering the dashboard the day a new
|
|
49
|
+
status value is added, which is the same quiet wrongness ADR 024, ADR
|
|
50
|
+
032 and ADR 034 all already refuse elsewhere. An exclusion degrades
|
|
51
|
+
differently from an inclusion when the data shape changes under it, and
|
|
52
|
+
"never" is a more honest word for what `default_where` is for than a
|
|
53
|
+
list that has to be kept in sync with every value a column might hold.
|
|
54
|
+
|
|
55
|
+
### What was considered
|
|
56
|
+
|
|
57
|
+
**Leave it, and require `_in` naming every value but the excluded one.**
|
|
58
|
+
Rejected for the reason above: it is not equivalent, it is a
|
|
59
|
+
maintenance trap that looks correct until a new value appears.
|
|
60
|
+
|
|
61
|
+
**Allow `not_eq`/`not_in` only through `where:`/`default_where`, not
|
|
62
|
+
through the reader's `q[...]`.** Considered, because the motivating case
|
|
63
|
+
is a frame's own permanent filter, not something a reader asks for.
|
|
64
|
+
Rejected: `narrow` is deliberately the one choke point every caller
|
|
65
|
+
shares (ADR 025), and splitting it into a predicate list parameterised
|
|
66
|
+
by which caller is asking is new configurability of exactly the kind
|
|
67
|
+
ADR 021 and ADR 023's test rejects, to protect nothing. ADR 040 already
|
|
68
|
+
says a fixed filter is a view filter, not an authorisation boundary: a
|
|
69
|
+
reader who can edit a pane URL can already ask for anything the
|
|
70
|
+
dimension allows, so refusing the reader `not_eq` while allowing a host
|
|
71
|
+
the same predicate on the same attribute buys no security, only a
|
|
72
|
+
second thing to explain.
|
|
73
|
+
|
|
74
|
+
**A fourth verb in the DSL, "exclude," alongside ADR 025's "select" and
|
|
75
|
+
"range" groupings.** Rejected. Nothing about `not_eq`/`not_in` needs a
|
|
76
|
+
name Ransack does not already give it; the project keeps the DSL a thin
|
|
77
|
+
layer over Ransack rather than a query language of its own (CLAUDE.md's
|
|
78
|
+
forkability vision), and "select"/"range" were never a formal surface,
|
|
79
|
+
only informal shorthand for reading ADR 025. Two more entries in the
|
|
80
|
+
existing list cost nothing that a new grouping would justify.
|
|
81
|
+
|
|
82
|
+
**Time dimensions get their own decision about whether they need it.**
|
|
83
|
+
Considered, and rejected as unnecessary work: no dashboard question
|
|
84
|
+
measured here or in #63 asks to exclude a single instant from a time
|
|
85
|
+
range, so nothing argues for building it. But `TIME_PREDICATES` is
|
|
86
|
+
`CATEGORICAL_PREDICATES + %w[gteq gt lteq lt]` (`lib/janela/dimension.rb`),
|
|
87
|
+
the same way it already inherits `not_null` without a time-specific
|
|
88
|
+
argument for that either. Carving `not_eq`/`not_in` out of the inherited
|
|
89
|
+
half would be a special case with nothing behind it. It stays inherited.
|
|
90
|
+
|
|
91
|
+
## Decision
|
|
92
|
+
|
|
93
|
+
**A categorical dimension's allowed predicates gain `not_eq` and
|
|
94
|
+
`not_in`, for the same reason `not_null` is already there: a real
|
|
95
|
+
dashboard question, already built on top of this list (ADR 043),
|
|
96
|
+
cannot be phrased any other way that survives a new value being added.**
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# lib/janela/dimension.rb
|
|
100
|
+
CATEGORICAL_PREDICATES = %w[eq in not_eq not_in null not_null].freeze
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`TIME_PREDICATES` inherits both, unchanged in its own definition. No new
|
|
104
|
+
DSL vocabulary is introduced: `not_eq` and `not_in` are exactly Ransack's
|
|
105
|
+
own predicate names, entries in the same flat list every other allowed
|
|
106
|
+
predicate already sits in.
|
|
107
|
+
|
|
108
|
+
This predicate is reachable through `where:` (ADR 040), `default_where`
|
|
109
|
+
(ADR 043) and a hand-built or shared `q[...]` link, the same as every
|
|
110
|
+
other allowed predicate. It is not reachable through a click: ADR 024
|
|
111
|
+
writes only `eq`/`in`, and toggling a value never produces an exclusion.
|
|
112
|
+
Worth saying plainly wherever this is documented, so nobody goes looking
|
|
113
|
+
in the frame UI for a gesture that was never built.
|
|
114
|
+
|
|
115
|
+
## Consequences
|
|
116
|
+
|
|
117
|
+
- #63's motivating sentence, "this queue never counts an archived row,"
|
|
118
|
+
becomes expressible as `default_where: { "status_not_eq" => "archived" }`
|
|
119
|
+
and stays true when a new status value is added, which the `_in`
|
|
120
|
+
workaround could not.
|
|
121
|
+
- **Additive, not breaking.** Nothing that worked before is refused now;
|
|
122
|
+
the list only widens. No `UPGRADING.md` entry is needed on that
|
|
123
|
+
account (ADR 015), though the release notes should say the predicate
|
|
124
|
+
exists, the same way a new capability is always announced.
|
|
125
|
+
- The README and any documentation listing a dimension's allowed
|
|
126
|
+
predicates needs the two new entries alongside the existing four, kept
|
|
127
|
+
in sync with `CATEGORICAL_PREDICATES` rather than restated by hand.
|
|
128
|
+
- `reject_oversized_filters!` (ADR 025) already bounds any predicate
|
|
129
|
+
whose `Ransack::Predicate#wants_array` is true, which includes
|
|
130
|
+
`not_in`, so the 1000 value ceiling applies to it without a code
|
|
131
|
+
change.
|
|
132
|
+
- Test coverage belongs in `definition_query_test.rb` (the choke point
|
|
133
|
+
itself), `fixed_filter_test.rb` and `frame_default_filter_test.rb` (the
|
|
134
|
+
two callers ADR 040 and ADR 043 built), so all three layers are proven
|
|
135
|
+
rather than only the one that motivated this. Not built here.
|
|
136
|
+
- What would change this decision: a host with a measured need to
|
|
137
|
+
exclude a specific instant from a time dimension, which nothing today
|
|
138
|
+
shows evidence of. The answer then is the same rule ADR 025 already
|
|
139
|
+
set: add it to the list in an ADR that names the dashboard question,
|
|
140
|
+
not by making the list configurable.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-29
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 003, ADR 005, ADR 006, ADR 008, ADR 018, ADR 024, ADR 025, ADR 037, ADR 040
|
|
5
|
+
Triggers:
|
|
6
|
+
- making a line chart, or a time table row, a click source
|
|
7
|
+
- a click that has to write more than one filter condition
|
|
8
|
+
- deciding what a time pane does with a range filter on its own dimension
|
|
9
|
+
- writing a time range into the page URL or a pane src
|
|
10
|
+
- drill-down, or narrowing a time pane's granularity by clicking a bucket
|
|
11
|
+
Topics: time, cross-filtering, urls, ransack, accessibility, roadmap
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 045: Clicking a Time Bucket Filters the Frame to Its Range
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
A time pane re-scopes when any other pane is clicked, but clicking a
|
|
19
|
+
bucket in it does nothing (#18). ADR 006 left that out deliberately:
|
|
20
|
+
"Clicking a category adds one Ransack condition, `status_eq=paid`.
|
|
21
|
+
Clicking a month means two, `placed_on_gteq` and `placed_on_lt`, and the
|
|
22
|
+
dashboard's filter model is a single key and value per toggle." It said
|
|
23
|
+
drill-down was the natural next decision and would get its own ADR. This
|
|
24
|
+
is that ADR, and it comes now rather than after 1.0 because of what ADR
|
|
25
|
+
037 says 1.0 is: the pane URL shape and the dashboard filter parameters
|
|
26
|
+
stop moving. A range is a new shape in exactly that grammar, and adding
|
|
27
|
+
it after the freeze is a breaking change to the surface rather than an
|
|
28
|
+
addition to it. Nobody reading a dashboard would call a line chart that
|
|
29
|
+
ignores clicks finished, either. It is the first pane a person tries to
|
|
30
|
+
click.
|
|
31
|
+
|
|
32
|
+
Four things were measured against the dummy before deciding anything.
|
|
33
|
+
|
|
34
|
+
**Two conditions work as a range, and the end is exclusive.** Against the
|
|
35
|
+
`placed_on` date column, `placed_on_gteq=2026-09-01` with
|
|
36
|
+
`placed_on_lt=2026-09-02` returns the one order on that day, of four in
|
|
37
|
+
all. The month equivalent returns all four. `_gteq` and `_lt` are
|
|
38
|
+
already on a time dimension's allowed list (ADR 025), so the guard needs
|
|
39
|
+
nothing added. Using `_lteq` for the end would make the end of one bucket
|
|
40
|
+
also the start of the next, which is why the pair is half open.
|
|
41
|
+
|
|
42
|
+
**A bucket's label cannot be turned back into a range.** Groupdate's keys
|
|
43
|
+
are labelled after the query (ADR 006): a day is `2026-09-01`, a week is
|
|
44
|
+
its Monday `2026-08-31`, but a month is `Sep 2026`, a quarter is
|
|
45
|
+
`Q3 2026` and a year is `2026`. The chart looks its filter up by label
|
|
46
|
+
(`filters_for`), so the range has to be computed on the server from the
|
|
47
|
+
bucket before the key is turned into a label, not parsed out of the label
|
|
48
|
+
afterwards.
|
|
49
|
+
|
|
50
|
+
**A time pane collapses to the bucket that was clicked.** `Query
|
|
51
|
+
#applicable_filters` drops a categorical pane's own dimension from its
|
|
52
|
+
filters, so the pane still shows the alternatives to what was picked, but
|
|
53
|
+
returns every filter for a time pane. With a range on `placed_on`, the
|
|
54
|
+
time pane's own query returns `{"2026-09-01" => 1}`: one point on a line.
|
|
55
|
+
That is the whole difficulty. If the click wrote the range and nothing
|
|
56
|
+
else changed, the pane you clicked would become a single dot, and so
|
|
57
|
+
would every other time pane over the same dimension.
|
|
58
|
+
|
|
59
|
+
**A time zone offset is accepted on a date column.** `2026-09-01T00:00:00Z`
|
|
60
|
+
and `2026-09-01T00:00:00+10:00` both filter the date column to the right
|
|
61
|
+
day, in UTC and under `Australia/Brisbane`. Not measured, because the
|
|
62
|
+
dummy has no time dimension on a timestamp column and `created_at` is not
|
|
63
|
+
ransackable there: the same pair against a datetime column with
|
|
64
|
+
non-UTC `Time.zone`. Whoever builds this checks it first (see
|
|
65
|
+
Consequences).
|
|
66
|
+
|
|
67
|
+
### What was considered
|
|
68
|
+
|
|
69
|
+
**Write one key, a range string.** `placed_on_between=2026-09-01..2026-09-02`
|
|
70
|
+
would keep the frame's one key and value per toggle. Rejected. Ransack
|
|
71
|
+
has no such predicate, so it would be a grammar Janela invents on top of
|
|
72
|
+
ADR 005's, which is built on `q[...]` a person can read and a host can
|
|
73
|
+
already write by hand (ADR 025). ADR 002 and CLAUDE.md are both against a
|
|
74
|
+
second query language, and ADR 006 already tells hosts they can pass
|
|
75
|
+
`placed_on_gteq` and `placed_on_lt` today.
|
|
76
|
+
|
|
77
|
+
**Have the click write keys of its own, so it never meets the host's.**
|
|
78
|
+
A `placed_on_clicked_from` pair, say, that a time pane ignores while it
|
|
79
|
+
keeps applying `_gteq` and `_lt`. Rejected as a second vocabulary for one
|
|
80
|
+
idea. The reader's own selection and a host's range are different
|
|
81
|
+
*sources*, and ADR 040 and ADR 043 already sort by source: a host's fixed
|
|
82
|
+
and default filters apply on a pane's own dimension, the reader's do not.
|
|
83
|
+
Time panes should follow that rule like every other pane, not carry a
|
|
84
|
+
private one.
|
|
85
|
+
|
|
86
|
+
**Narrow, the way a BI tool drills.** Clicking `Sep 2026` changes that
|
|
87
|
+
pane's granularity to weeks within September (#18's second option).
|
|
88
|
+
Rejected here, and deferred, not refused. It changes what one pane shows
|
|
89
|
+
rather than what the frame is filtered to, so it is a separate gesture
|
|
90
|
+
with its own question: how to get back out, and whether it belongs in the
|
|
91
|
+
URL (ADR 008). The filter is useful alone, and drill-down can be added
|
|
92
|
+
on top of it without changing anything decided below.
|
|
93
|
+
|
|
94
|
+
**Select more than one bucket.** Ctrl-click to add a second day, as ADR
|
|
95
|
+
024 does for values. Rejected. Two `_gteq`/`_lt` pairs on one attribute
|
|
96
|
+
are not two ranges. Ransack ANDs its conditions, so two different days
|
|
97
|
+
return no rows at all, silently, which is the failure ADR 024 measured for
|
|
98
|
+
`_null` with `_in`. Ransack's `g[]` grouping could express an OR and is
|
|
99
|
+
rejected for the reason ADR 024 gave: it makes the URL unreadable.
|
|
100
|
+
Adjacent buckets could be merged into one longer range, but that is a
|
|
101
|
+
different gesture with a different rule about gaps, and nothing here asks
|
|
102
|
+
for it.
|
|
103
|
+
|
|
104
|
+
## Decision
|
|
105
|
+
|
|
106
|
+
**Clicking a bucket on a time pane filters the frame to that bucket's
|
|
107
|
+
half-open range, written as the dimension's `_gteq` and `_lt`. A time
|
|
108
|
+
pane shows the alternatives to its own selection, like every other pane,
|
|
109
|
+
and highlights the buckets inside it.**
|
|
110
|
+
|
|
111
|
+
**The range is the bucket's start and the next bucket's start.** The
|
|
112
|
+
server computes both from the bucket Groupdate returned, at the pane's
|
|
113
|
+
granularity and with the week starting on `Date.beginning_of_week`, and
|
|
114
|
+
hands the chart a pair per label. A click on `Sep 2026` writes:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
q[placed_on_gteq]=2026-09-01&q[placed_on_lt]=2026-10-01
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
For a date column the values are dates. For a timestamp they are the
|
|
121
|
+
start in `Time.zone`, ISO 8601 with its offset, so the bucket a person
|
|
122
|
+
clicked is the bucket the filter selects. The URL stays readable and
|
|
123
|
+
editable, and a range a host passes by hand behaves identically.
|
|
124
|
+
|
|
125
|
+
**The toggle carries a selection, and a selection can be more than one
|
|
126
|
+
condition.** A value click is a selection of one condition, as it is
|
|
127
|
+
today. A bucket click is a selection of two. The frame controller treats
|
|
128
|
+
either as one thing to add, replace or clear, so `clearDimension` clears
|
|
129
|
+
the dimension's `gteq`, `gt`, `lteq` and `lt` as well as `in`, `null` and
|
|
130
|
+
`eq`. Clicking the selected bucket clears it, the same as a value.
|
|
131
|
+
|
|
132
|
+
**A modifier click is a plain click on a time pane.** A range is not a
|
|
133
|
+
set, for the reason above, so Ctrl and Cmd change nothing here. This is
|
|
134
|
+
the one place ADR 024's gesture does not apply, and the pane says so in
|
|
135
|
+
its accessible description rather than leaving it to be discovered.
|
|
136
|
+
|
|
137
|
+
**A time pane ignores the reader's range on its own dimension, and
|
|
138
|
+
highlights the buckets that fall inside it.** This reverses what
|
|
139
|
+
`applicable_filters` does for time panes today and follows the rule ADR
|
|
140
|
+
040 and ADR 043 already set: a host's fixed and default filters narrow a
|
|
141
|
+
pane on its own dimension, the reader's selection does not. A bucket is
|
|
142
|
+
selected when its whole range lies within the filter's, so clicking a
|
|
143
|
+
month highlights every day of that month on a day chart. Panes over the
|
|
144
|
+
same dimension at different granularities agree that way without knowing
|
|
145
|
+
about each other.
|
|
146
|
+
|
|
147
|
+
**Granularity does not change.** Drill-down stays open (#18's second
|
|
148
|
+
option), to be decided on top of this rather than folded into it.
|
|
149
|
+
|
|
150
|
+
**Keyboard follows ADR 018.** A time table's rows render their labels as
|
|
151
|
+
buttons, as a categorical table's do, and Enter is the click. A line
|
|
152
|
+
chart stays a mouse surface, as ADR 024 accepts for bars, and the table
|
|
153
|
+
that renders the same data is the operable one.
|
|
154
|
+
|
|
155
|
+
**A stored pane is still not clickable** (ADR 009). It is the record of a
|
|
156
|
+
moment.
|
|
157
|
+
|
|
158
|
+
## Consequences
|
|
159
|
+
|
|
160
|
+
- A dashboard can answer "what happened in March?" by clicking March,
|
|
161
|
+
and every other pane re-scopes. That is the first thing anyone tries on
|
|
162
|
+
a line chart and it now works.
|
|
163
|
+
- **Breaking, narrowly.** A reader-supplied `placed_on_gteq` or `_lt` in a
|
|
164
|
+
URL used to narrow a time pane's own series. It now scopes every other
|
|
165
|
+
pane and leaves that one showing the whole series with the range
|
|
166
|
+
highlighted. A host's `where:` and `default_where` still narrow it, so
|
|
167
|
+
the supported way to fix a range is unchanged. It needs an
|
|
168
|
+
`UPGRADING.md` entry naming both routes, and the release carrying it is
|
|
169
|
+
a minor one (ADR 015). The doctor cannot see this from source, because
|
|
170
|
+
the filter arrives at runtime.
|
|
171
|
+
- `Query#clickable?` stops excluding time panes, `filter_params` and
|
|
172
|
+
`filters_for` return a pair for one, and `selected_values` for a time
|
|
173
|
+
pane becomes a question about ranges. `Definition#query` has to keep the
|
|
174
|
+
bucket start until after the labelling step, since the label is
|
|
175
|
+
lossy. These are the parts a forker is most likely to touch, and the
|
|
176
|
+
reason they are named here.
|
|
177
|
+
- The frame controller's toggle grows from one key and value to a
|
|
178
|
+
selection. A host that dispatches `janela--frame:toggle` itself keeps
|
|
179
|
+
working, because a single key and value is still a selection.
|
|
180
|
+
- The case measured in Context as unmeasured was measured in the build.
|
|
181
|
+
On SQLite under `Australia/Brisbane`, a timestamp at 14:30 UTC on 1
|
|
182
|
+
September was bucketed into 2 September, a `_gteq`/`_lt` pair written as
|
|
183
|
+
ISO 8601 with `+10:00` for 2 September returned it and the pair for 1
|
|
184
|
+
September returned nothing, and date-only strings gave the same answers.
|
|
185
|
+
Groupdate honoured the zone on SQLite there, which ADR 006 said it does
|
|
186
|
+
not, so that line of ADR 006 is out of date. Not measured on PostgreSQL
|
|
187
|
+
or MySQL, whose zone handling is the database's own.
|
|
188
|
+
- **The demo proves it.** Until a line pane on the home page's "This one
|
|
189
|
+
is live" frame filters the other panes when a day is clicked, and a
|
|
190
|
+
system test does the same, this ADR has been read but not shown to work.
|
|
191
|
+
The build includes both, and the home page's copy changes from "three
|
|
192
|
+
panes" to match.
|
|
193
|
+
- **This moves #18 into 1.0.** ADR 037 sorted #18 after 1.0 on the ground
|
|
194
|
+
that it "needs an ADR of its own before any code", and that ADR now
|
|
195
|
+
exists. What moves it is the ground ADR 037 itself argues: a range is a
|
|
196
|
+
new shape in the URL grammar the freeze covers. The milestone gains a
|
|
197
|
+
ninth issue, `docs/roadmap.md` (and the count in its illustration) is
|
|
198
|
+
updated with it, and ADR 037 is left as written. The sort in it was
|
|
199
|
+
right at the time, and an ADR records what was decided then.
|
|
200
|
+
- ADR 006's paragraph "Time panes are not yet click sources" is
|
|
201
|
+
superseded by this one. The rest of ADR 006 stands.
|
|
202
|
+
- What would change this decision: a real dashboard that needs two
|
|
203
|
+
separate periods selected at once, which would have to be an OR and
|
|
204
|
+
would have to justify the URL cost ADR 024 refused; or drill-down
|
|
205
|
+
turning out to be the gesture people expect from a click, in which case
|
|
206
|
+
the follow-up ADR may swap which one a plain click does.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-29
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 016, ADR 018, ADR 024, ADR 026, ADR 036, ADR 037, ADR 042
|
|
5
|
+
Triggers:
|
|
6
|
+
- adding a renderer, or a colour a renderer draws with
|
|
7
|
+
- adding a custom property to janela.css or documenting one in docs/theming.md
|
|
8
|
+
- drawing a part-to-whole split, a doughnut or a pie
|
|
9
|
+
- deciding what a chart does with more categories than it has colours
|
|
10
|
+
- a chart pane that must be readable, printable or operable without JavaScript
|
|
11
|
+
Topics: rendering, styling, theming, charts, accessibility, roadmap
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 046: A Ring Is Server Drawn SVG, and a Palette Is Eight Fixed Colours
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
#30, raised from a real install: a two-category split has to be drawn as
|
|
19
|
+
two bars, and a ring reads better for a part-to-whole. It asks for
|
|
20
|
+
`doughnut` and `pie` renderers, a categorical palette to make them
|
|
21
|
+
readable, and two smaller fixes to the chart controller. It assumes
|
|
22
|
+
Chart.js, because the controller already hands its type straight to
|
|
23
|
+
it. ADR 037 puts it in 1.0 and #30 is the only item there that adds to
|
|
24
|
+
what a pane can be drawn as.
|
|
25
|
+
|
|
26
|
+
Measured against the demo before deciding:
|
|
27
|
+
|
|
28
|
+
- `renderer: "doughnut"` raises `Janela::BadRequest` today. The whitelist
|
|
29
|
+
is `Janela::Query::RENDERERS` (`%w[table bar line]`). The issue says
|
|
30
|
+
`Pane::RENDERERS`, which does not exist. The pane form, `Janela.renderers`,
|
|
31
|
+
the `janela.renderers.*` locale keys, the gallery and stored pane rows
|
|
32
|
+
all read that one constant.
|
|
33
|
+
- Chart.js is vendored with its doughnut and pie controllers, so the
|
|
34
|
+
canvas route needs no dependency.
|
|
35
|
+
- Switching a live bar chart's config to `doughnut` with the controller's
|
|
36
|
+
own options gives a ring in one colour: `backgroundColor` holds one
|
|
37
|
+
distinct value across four segments, so the boundaries are hairlines.
|
|
38
|
+
The unconditional `y` scale is configured on it too. Both complaints in
|
|
39
|
+
the issue are real.
|
|
40
|
+
- `--janela-accent` is the only colour Janela publishes
|
|
41
|
+
(`docs/theming.md`). Vitral's `--vitral-pane-1..5` are translucent glass
|
|
42
|
+
tints for backgrounds, not colours to draw data in.
|
|
43
|
+
- The demo's dimensions have four, two and three values (one of them the
|
|
44
|
+
`(none)` group). A categorical dimension is bounded only by 1000
|
|
45
|
+
(ADR 025), so a palette will meet more categories than it has colours.
|
|
46
|
+
- The dataviz palette validator (a fixed-order eight colour categorical
|
|
47
|
+
set) passes on adjacent pairs in light mode with the accent as slot 1:
|
|
48
|
+
worst adjacent colour vision separation 9.1, normal vision 19.6, against
|
|
49
|
+
targets of 8 and 15. It warns that four slots (accent, aqua, yellow,
|
|
50
|
+
pink) fall under 3:1 contrast against a light surface, which obliges
|
|
51
|
+
visible labels or a table view. The same eight hues stepped for a dark
|
|
52
|
+
surface pass too (8.4, 19.3, contrast all 3:1 or better).
|
|
53
|
+
|
|
54
|
+
### What was considered
|
|
55
|
+
|
|
56
|
+
**Chart.js `doughnut` and `pie`, as the issue proposes.** The smallest
|
|
57
|
+
change: a whitelist entry, a palette, and conditionals around the scale
|
|
58
|
+
and legend. Rejected. ADR 026 makes server rendered HTML and CSS the
|
|
59
|
+
default, a library earning its place by doing what the document cannot,
|
|
60
|
+
and a ring is inline SVG arcs, which the document can do. A canvas is
|
|
61
|
+
blank without JavaScript, does not print as text, has nothing focusable
|
|
62
|
+
behind it (ADR 024 already says as much of bars) and puts the labels
|
|
63
|
+
inside a bitmap. It would also leave the issue's two "smaller things" as
|
|
64
|
+
work: the scale and legend switches exist only because the canvas has to
|
|
65
|
+
be told what it is.
|
|
66
|
+
|
|
67
|
+
**A CSS `conic-gradient` ring.** Also HTML and CSS, and shorter than SVG.
|
|
68
|
+
Rejected. A gradient is one element, so a slice has no hit target, no
|
|
69
|
+
native tooltip and no way to take a gap or a dimmed state of its own.
|
|
70
|
+
Every one of those would have to be rebuilt from the legend alone.
|
|
71
|
+
|
|
72
|
+
**Cycle the palette.** #30 says "N distinct colours cycling". Rejected.
|
|
73
|
+
The 9th slice reusing the 1st colour draws two different categories the
|
|
74
|
+
same, next to a legend that says they are different. The rule is fixed
|
|
75
|
+
order and a neutral past the last slot.
|
|
76
|
+
|
|
77
|
+
**Leave bars on the one accent.** The recommendation put to the
|
|
78
|
+
maintainer, to avoid changing panes people have already themed.
|
|
79
|
+
Overridden: the palette applies to bars as well. See Consequences for
|
|
80
|
+
what that changes and what a host does.
|
|
81
|
+
|
|
82
|
+
## Decision
|
|
83
|
+
|
|
84
|
+
**`doughnut` and `pie` are renderers drawn on the server as inline SVG
|
|
85
|
+
with a legend that is a table of buttons, and Janela publishes a
|
|
86
|
+
categorical palette of eight fixed colours that bars, doughnuts and pies
|
|
87
|
+
draw with.**
|
|
88
|
+
|
|
89
|
+
**The ring is SVG, and its legend is the control.** Each slice is a `path`
|
|
90
|
+
carrying the same `janela--frame#toggle` action a table button does, a
|
|
91
|
+
`<title>` child so a hover names it, and a gap between slices so no
|
|
92
|
+
boundary depends on colour alone. A doughnut is a pie with a hole. The
|
|
93
|
+
pane is a `figure` with a `figcaption` title (ADR 042), the SVG points to
|
|
94
|
+
it, and beside the ring is a legend of real buttons, each with a swatch,
|
|
95
|
+
the label and the measure's formatted value (ADR 020). The legend is what a
|
|
96
|
+
keyboard, a screen reader and a touch user operate, and it is the visible
|
|
97
|
+
labelling the palette's contrast warning obliges. Ctrl and Cmd add and
|
|
98
|
+
remove as in ADR 024, because they are the same buttons. It works,
|
|
99
|
+
printed and unstyled, with no JavaScript, and the engine's own frame page
|
|
100
|
+
draws it (ADR 026, rule 3).
|
|
101
|
+
|
|
102
|
+
**A ring draws only what a ring can.** A slice with a zero value is left
|
|
103
|
+
out of the ring and stays in the legend. A pane with a negative value is
|
|
104
|
+
drawn as the table renderer's markup instead, since a part of a whole
|
|
105
|
+
cannot be negative, and the pane says so in words rather than drawing
|
|
106
|
+
something wrong. Nothing raises.
|
|
107
|
+
|
|
108
|
+
**The palette is eight ordered custom properties and a neutral.**
|
|
109
|
+
|
|
110
|
+
```css
|
|
111
|
+
:root {
|
|
112
|
+
--janela-series-1: var(--janela-accent);
|
|
113
|
+
--janela-series-2: #eb6834;
|
|
114
|
+
--janela-series-3: #1baf7a;
|
|
115
|
+
--janela-series-4: #eda100;
|
|
116
|
+
--janela-series-5: #e87ba4;
|
|
117
|
+
--janela-series-6: #008300;
|
|
118
|
+
--janela-series-7: #4a3aa7;
|
|
119
|
+
--janela-series-8: #e34948;
|
|
120
|
+
--janela-series-other: #8c8c8c;
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Slot 1 is the accent, so a host that has set `--janela-accent` still has
|
|
125
|
+
its own colour on the first bar. The set is the reference instance of the
|
|
126
|
+
validated palette with the accent standing in for its blue, and it was
|
|
127
|
+
run through the validator in that form. A colour is chosen by a
|
|
128
|
+
category's position in the result, first to eighth, and every category
|
|
129
|
+
past the eighth takes `--janela-series-other`. Never cycled. A host
|
|
130
|
+
that wants a longer pane says `limit: 8` (ADR 007), which is the honest
|
|
131
|
+
answer for a ring anyway, and the documentation says a ring suits few
|
|
132
|
+
slices. Lines are one series and keep the accent.
|
|
133
|
+
|
|
134
|
+
**The selected state is the existing one, over each slice's own colour.**
|
|
135
|
+
With nothing selected every slice and bar is solid. With a selection the
|
|
136
|
+
unselected ones dim to the same reduced opacity a bar already uses
|
|
137
|
+
(`colours()` in `chart_controller.js`) and the selected ones stay solid.
|
|
138
|
+
A dimmed set of eight colours stays distinguishable because opacity is
|
|
139
|
+
applied to the slice, not the palette.
|
|
140
|
+
|
|
141
|
+
**Published, and therefore frozen at 1.0.** `--janela-series-1..8`,
|
|
142
|
+
`--janela-series-other` and the ring's and legend's class names join the
|
|
143
|
+
contract in `docs/theming.md` in the same change (ADR 036). The class
|
|
144
|
+
names are chosen in the build and documented there, not here. The dark
|
|
145
|
+
values that passed the validator are recorded in `docs/theming.md` for a
|
|
146
|
+
theme to use. `janela.css` ships no dark scheme today (ADR 023 leaves that
|
|
147
|
+
to a theme), and adding one is not this decision.
|
|
148
|
+
|
|
149
|
+
**The chart controller changes only for bars.** It draws bar and line, so
|
|
150
|
+
the two conditionals in the issue (`beginAtZero` on an axis-less chart and
|
|
151
|
+
the hidden legend) do not arise and are not added. Bars read their
|
|
152
|
+
per-bar colours from the palette on the element, the way `accentColour`
|
|
153
|
+
already reads `--janela-accent`.
|
|
154
|
+
|
|
155
|
+
## Consequences
|
|
156
|
+
|
|
157
|
+
- A part-to-whole split has a renderer, and it is a table to a screen
|
|
158
|
+
reader, a picture to everyone else, and a link on a slice to anyone with
|
|
159
|
+
a mouse.
|
|
160
|
+
- `Query::RENDERERS` becomes `table bar line doughnut pie`. `Janela
|
|
161
|
+
.renderers`, the pane form, the locale keys (`janela.renderers.doughnut`
|
|
162
|
+
and `.pie`), the gallery and its tests follow from that one constant and
|
|
163
|
+
need entries. A model with no suitable dimension shows the renderer as
|
|
164
|
+
unavailable, as the gallery does for any other. Additive.
|
|
165
|
+
- **Bars change appearance, in a minor release.** Every existing bar pane
|
|
166
|
+
goes from one accent colour to one colour per bar. A host that themed
|
|
167
|
+
bars through `--janela-accent` keeps that colour on the first bar and
|
|
168
|
+
the accent for selection and hover, and gets new colours on the rest.
|
|
169
|
+
A host that wants the old look sets `--janela-series-2` to
|
|
170
|
+
`--janela-series-8` to the accent. It needs an `UPGRADING.md` entry
|
|
171
|
+
saying exactly that, in the release that carries it (ADR 015). The
|
|
172
|
+
doctor cannot see this from source.
|
|
173
|
+
- **Colour follows position, not the category.** A filter on another
|
|
174
|
+
pane can reorder this pane's categories, and a colour then moves with
|
|
175
|
+
its rank. The dataviz rule that colour follows the entity, so a filter
|
|
176
|
+
never repaints the survivors, is not met, because Janela has no registry
|
|
177
|
+
of what a category is. The label is always beside the colour (legend or
|
|
178
|
+
axis), which is the reason this is acceptable, and the cost is real. What
|
|
179
|
+
would change it: a host declaring a colour for a dimension value,
|
|
180
|
+
which is a new piece of DSL and its own ADR.
|
|
181
|
+
- Four slots fall under 3:1 contrast on a light surface. The legend is the
|
|
182
|
+
visible labelling the validator asks for, and a bar has its axis.
|
|
183
|
+
Adjacent colour vision separation is 9.1 and above, but only the first
|
|
184
|
+
three slots pass when every pair is compared, and a ring with a fifth
|
|
185
|
+
slice puts colours side by side that are not adjacent in the palette.
|
|
186
|
+
That is why the gap and the legend exist and why the documentation
|
|
187
|
+
recommends few slices.
|
|
188
|
+
- A slice's hit target is small when it is a small share. The legend row
|
|
189
|
+
is always the full target.
|
|
190
|
+
- The demo proves it. The home page's "This one is live" frame gains a
|
|
191
|
+
doughnut pane (revenue by status), the gallery shows both new renderers
|
|
192
|
+
through `Janela.renderers`, and a system test clicks a slice and a legend
|
|
193
|
+
button and sees the other panes re-scope. The copy that says how many
|
|
194
|
+
panes there are changes with it, once, together with the time pane from
|
|
195
|
+
ADR 045.
|
|
196
|
+
- What would change this decision: a chart that needs what SVG cannot do,
|
|
197
|
+
such as animation or many thousands of marks, is the case ADR 026 keeps
|
|
198
|
+
the library for, and this one does not fit it. A host that needs a
|
|
199
|
+
category to keep its colour across filters is the case above.
|
data/docs/decisions/INDEX.md
CHANGED
|
@@ -22,27 +22,27 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
22
22
|
|-------|------|
|
|
23
23
|
| **Vision, scope, forkability** | 001, 010, 012, 037 |
|
|
24
24
|
| **Open-source & host-decoupling** | 001, 022, 036, 041 |
|
|
25
|
-
| **DSL & query layer** | 002, 006, 007, 020, 025, 038 |
|
|
25
|
+
| **DSL & query layer** | 002, 006, 007, 020, 025, 038, 044 |
|
|
26
26
|
| **Dependencies** | 002, 003, 004, 006, 017, 025 |
|
|
27
27
|
| **Authorisation** | 002, 003, 004, 009, 017, 019, 022, 032, 033, 034, 035, 039, 040 |
|
|
28
28
|
| **Performance & storage** | 007, 017, 025 |
|
|
29
29
|
| **Ordering & formatting** | 007, 020, 038 |
|
|
30
|
-
| **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025, 040, 043 |
|
|
30
|
+
| **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025, 040, 043, 044, 045 |
|
|
31
31
|
| **Layouts & views** | 011, 012, 016, 018, 020, 027, 039 |
|
|
32
|
-
| **CSS & styling** | 016, 018, 023, 026, 027, 036, 042 |
|
|
33
|
-
| **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043 |
|
|
32
|
+
| **CSS & styling** | 016, 018, 023, 026, 027, 036, 042, 046 |
|
|
33
|
+
| **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043, 044 |
|
|
34
34
|
| **Naming rule** | 014, 023, 036 |
|
|
35
|
-
| **JavaScript delivery & charts** | 004, 006, 026, 042 |
|
|
36
|
-
| **Time dimensions** | 006, 025 |
|
|
37
|
-
| **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041 |
|
|
35
|
+
| **JavaScript delivery & charts** | 004, 006, 026, 042, 046 |
|
|
36
|
+
| **Time dimensions** | 006, 025, 045 |
|
|
37
|
+
| **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041, 045 |
|
|
38
38
|
| **Snapshots & publishing** | 009, 020, 028, 033, 034 |
|
|
39
39
|
| **AI agents & guidance** | 010, 015, 021 |
|
|
40
40
|
| **The doctor & checks** | 021, 025, 032, 033, 035 |
|
|
41
41
|
| **Releases & upgrades** | 015, 021, 032, 034, 035, 036, 037, 042 |
|
|
42
|
-
| **Accessibility & keyboard** | 024, 042 |
|
|
43
|
-
| **Security** | 003, 025, 028, 031, 032, 034, 035 |
|
|
42
|
+
| **Accessibility & keyboard** | 024, 042, 045, 046 |
|
|
43
|
+
| **Security** | 003, 025, 028, 031, 032, 034, 035, 044 |
|
|
44
44
|
| **Testing** | 003 |
|
|
45
|
-
| **Roadmap & planning** | 001, 037 |
|
|
45
|
+
| **Roadmap & planning** | 001, 037, 045, 046 |
|
|
46
46
|
|
|
47
47
|
## Chronological
|
|
48
48
|
|
|
@@ -91,7 +91,10 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
91
91
|
| 041 | A Host Finds Its Frame by Owner and Key | 2026-09-24 | Accepted |
|
|
92
92
|
| 042 | A Chart's Title Is a Figcaption, and the Canvas Points to It | 2026-09-28 | Accepted |
|
|
93
93
|
| 043 | A Frame's Default Filter Names the Model It Narrows | 2026-09-28 | Accepted |
|
|
94
|
+
| 044 | A Categorical Dimension Can Say What It Excludes, Not Only What It Includes | 2026-09-29 | Accepted |
|
|
95
|
+
| 045 | Clicking a Time Bucket Filters the Frame to Its Range | 2026-09-29 | Accepted |
|
|
96
|
+
| 046 | A Ring Is Server Drawn SVG, and a Palette Is Eight Fixed Colours | 2026-09-29 | Accepted |
|
|
94
97
|
|
|
95
98
|
## Next number
|
|
96
99
|
|
|
97
|
-
Next ADR:
|
|
100
|
+
Next ADR: 047
|