janela 0.2.1 → 0.4.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 +63 -1
- data/LICENSE.txt +1 -1
- data/README.md +244 -23
- data/UPGRADING.md +172 -0
- data/app/assets/javascripts/janela/chart_controller.js +23 -10
- data/app/assets/javascripts/janela/frame_controller.js +188 -0
- data/app/assets/javascripts/janela/vitral_controller.js +263 -0
- data/app/assets/stylesheets/janela.css +164 -0
- data/app/assets/stylesheets/vitral.css +343 -0
- data/app/controllers/janela/application_controller.rb +25 -0
- data/app/controllers/janela/frames_controller.rb +57 -0
- data/app/controllers/janela/panes_controller.rb +66 -13
- data/app/controllers/janela/queries_controller.rb +17 -0
- data/app/controllers/janela/{snapshot_panes_controller.rb → snapshot_queries_controller.rb} +7 -5
- data/app/helpers/janela/{dashboard_helper.rb → frames_helper.rb} +26 -9
- data/app/models/janela/frame.rb +28 -0
- data/app/models/janela/pane.rb +102 -99
- data/app/models/janela/query.rb +162 -0
- data/app/models/janela/snapshot.rb +5 -5
- data/app/views/janela/frames/_card.html.erb +7 -0
- data/app/views/janela/frames/_form.html.erb +23 -0
- data/app/views/janela/frames/_frame.html.erb +5 -0
- data/app/views/janela/frames/_pane.html.erb +12 -0
- data/app/views/janela/frames/edit.html.erb +25 -0
- data/app/views/janela/frames/index.html.erb +15 -0
- data/app/views/janela/frames/new.html.erb +5 -0
- data/app/views/janela/frames/show.html.erb +9 -0
- data/app/views/janela/panes/_form.html.erb +58 -0
- data/app/views/janela/panes/_row.html.erb +12 -0
- data/app/views/janela/panes/edit.html.erb +5 -0
- data/app/views/janela/panes/new.html.erb +22 -0
- data/app/views/janela/panes/show.html.erb +3 -46
- data/app/views/janela/queries/_query.html.erb +47 -0
- data/app/views/janela/queries/show.html.erb +4 -0
- data/app/views/janela/shared/_errors.html.erb +7 -0
- data/app/views/layouts/janela/application.html.erb +22 -5
- data/config/importmap.rb +2 -1
- data/config/locales/en.yml +65 -0
- data/config/routes.rb +15 -2
- data/db/migrate/20260916000001_create_janela_frames.rb +13 -0
- data/db/migrate/20260916000002_create_janela_panes.rb +22 -0
- data/docs/decisions/001-built-to-be-forked.md +4 -0
- data/docs/decisions/009-snapshots.md +5 -2
- data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +8 -4
- data/docs/decisions/012-frames-and-panes-are-data.md +166 -0
- data/docs/decisions/013-naming-and-addressing-frames.md +119 -0
- data/docs/decisions/014-corrections-before-frames-are-built.md +222 -0
- data/docs/decisions/015-how-breaking-change-is-communicated.md +114 -0
- data/docs/decisions/016-the-styling-vocabulary.md +100 -0
- data/docs/decisions/017-janela-owns-no-data-store.md +113 -0
- data/docs/decisions/018-a-table-is-the-universal-renderer.md +75 -0
- data/docs/decisions/019-a-created-frame-asks-the-host-who-owns-it.md +79 -0
- data/docs/decisions/020-formatting-belongs-to-the-measure.md +93 -0
- data/docs/decisions/021-a-check-has-a-name-a-host-can-silence.md +84 -0
- data/docs/decisions/022-host-route-helpers-work-inside-the-engine.md +104 -0
- data/docs/decisions/023-vitral-is-a-theme-not-the-stylesheet.md +86 -0
- data/docs/decisions/024-selecting-more-than-one-value.md +127 -0
- data/docs/decisions/INDEX.md +25 -7
- data/docs/multi-tenancy.md +175 -0
- data/docs/naming.md +172 -0
- data/lib/janela/definition.rb +5 -5
- data/lib/janela/doctor.rb +219 -0
- data/lib/janela/engine.rb +24 -2
- data/lib/janela/host_routes.rb +31 -0
- data/lib/janela/measure.rb +65 -4
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +24 -0
- data/lib/tasks/janela.rake +6 -0
- metadata +57 -4
- data/app/assets/javascripts/janela/dashboard_controller.js +0 -58
data/UPGRADING.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Upgrading Janela
|
|
2
|
+
|
|
3
|
+
What to do, in order, when a release needs you to act. For what
|
|
4
|
+
changed and why, see [CHANGELOG.md](CHANGELOG.md); for why it was
|
|
5
|
+
decided, see [docs/decisions](docs/decisions/INDEX.md).
|
|
6
|
+
|
|
7
|
+
After any upgrade, run:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bin/rails janela:doctor
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
It reads your application and lists what still needs changing.
|
|
14
|
+
|
|
15
|
+
## 0.3.0 to 0.4.0
|
|
16
|
+
|
|
17
|
+
Selecting more than one value in a dimension (ADR 024). Most hosts do
|
|
18
|
+
nothing: the gesture, the predicate and the keys all arrive on their
|
|
19
|
+
own. Two things need you only if you have reached into Janela's own
|
|
20
|
+
markup.
|
|
21
|
+
|
|
22
|
+
**1. `selected_value` is now `selected_values`.**
|
|
23
|
+
|
|
24
|
+
Only if you overrode a pane view. It returns an array, because a
|
|
25
|
+
dimension can now hold more than one value, and the null group is in
|
|
26
|
+
it like any other label:
|
|
27
|
+
|
|
28
|
+
```erb
|
|
29
|
+
- <% if label.to_s == query.selected_value.to_s %>
|
|
30
|
+
+ <% if query.selected?(label) %>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**2. A click now writes `_in` rather than `_eq`.**
|
|
34
|
+
|
|
35
|
+
A shared link is `?q[status_in][]=paid` instead of `?q[status_eq]=paid`.
|
|
36
|
+
Links you sent before keep working, because Ransack reads both and
|
|
37
|
+
Janela still marks an `_eq` value as selected. You only need to act if
|
|
38
|
+
something of yours parses Janela's URLs or asserts on them, such as a
|
|
39
|
+
test:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
- assert_includes page.current_url, "q[status_eq]=paid"
|
|
43
|
+
+ assert_includes page.current_url, "q[status_in][]=paid"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Nothing else changed.** Ctrl or Cmd click adds a value to a
|
|
47
|
+
selection, Escape clears the frame's filters, and both work from the
|
|
48
|
+
keyboard because a value is a real button and Enter carries the same
|
|
49
|
+
modifier. A chart highlights every selected bar.
|
|
50
|
+
|
|
51
|
+
## 0.2.1 to 0.3.0
|
|
52
|
+
|
|
53
|
+
Two things arrive together. Janela's own words settle on frames and
|
|
54
|
+
panes, which is a rename across your application: a frame is the
|
|
55
|
+
dashboard, a pane is one visual in it. And a dashboard can now be a
|
|
56
|
+
database record rather than only ERB, which is what the new tables and
|
|
57
|
+
the engine's own pages are for.
|
|
58
|
+
|
|
59
|
+
Step 1 is the one that breaks a page if you skip it. The renames after
|
|
60
|
+
it can be done in any order, and `janela:doctor` will tell you what you
|
|
61
|
+
have missed.
|
|
62
|
+
|
|
63
|
+
**1. Run the migrations.**
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
bin/rails janela:install:migrations
|
|
67
|
+
bin/rails db:migrate
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Required if you mount the engine, even if you never intend to store a
|
|
71
|
+
dashboard. From this release the mount root serves an index of frames,
|
|
72
|
+
so without the tables `/dashboards` raises where it used to work.
|
|
73
|
+
|
|
74
|
+
**2. Rename the Stimulus controller you register.**
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
// app/javascript/controllers/index.js, or wherever you register it
|
|
78
|
+
- import JanelaDashboardController from "@retail-tasker/janela/dashboard_controller"
|
|
79
|
+
- application.register("janela--dashboard", JanelaDashboardController)
|
|
80
|
+
+ import JanelaFrameController from "@retail-tasker/janela/frame_controller"
|
|
81
|
+
+ application.register("janela--frame", JanelaFrameController)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
On importmap the path is `janela/frame_controller` rather than
|
|
85
|
+
`janela/dashboard_controller`. On npm, reinstall so the new export
|
|
86
|
+
path resolves:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
yarn add github:retail-tasker/janela#v0.4.0 # or bump your version range
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**3. Rename the helper that wraps your panes.**
|
|
93
|
+
|
|
94
|
+
```erb
|
|
95
|
+
- <%= janela_dashboard do %>
|
|
96
|
+
+ <%= janela_frame do %>
|
|
97
|
+
<%= janela_pane Order, :revenue %>
|
|
98
|
+
<% end %>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`janela_pane` and `janela_snapshot_pane` are unchanged.
|
|
102
|
+
|
|
103
|
+
**4. Rename any Janela data attributes in your own markup.**
|
|
104
|
+
|
|
105
|
+
Anything you wrote by hand, most likely a clear button:
|
|
106
|
+
|
|
107
|
+
```erb
|
|
108
|
+
- <button data-action="janela--dashboard#clear">Clear filters</button>
|
|
109
|
+
+ <button data-action="janela--frame#clear">Clear filters</button>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The same applies to `data-janela--dashboard-target` and
|
|
113
|
+
`data-janela--dashboard-*-param` if you built your own controls.
|
|
114
|
+
|
|
115
|
+
**5. Rename the constants, if you reference them.**
|
|
116
|
+
|
|
117
|
+
Most applications do not.
|
|
118
|
+
|
|
119
|
+
| Was | Now |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| `Janela::Pane` | `Janela::Query` |
|
|
122
|
+
| `Janela::Pane#frame_id` | `Janela::Query#turbo_frame_id` |
|
|
123
|
+
| `Janela::DashboardHelper` | `Janela::FramesHelper` |
|
|
124
|
+
| `Janela::PanesController` | `Janela::QueriesController` |
|
|
125
|
+
| `Janela::SnapshotPanesController` | `Janela::SnapshotQueriesController` |
|
|
126
|
+
|
|
127
|
+
`Janela::Pane` is reserved for a database record in a later release,
|
|
128
|
+
which is why the runtime object had to give the name up.
|
|
129
|
+
|
|
130
|
+
If you override Janela's view, move your copy from
|
|
131
|
+
`app/views/janela/panes/` to `app/views/janela/queries/`.
|
|
132
|
+
|
|
133
|
+
**6. Tell Janela what a new frame belongs to, if your policy asks.**
|
|
134
|
+
|
|
135
|
+
Only for a multi tenant host. If your Pundit scope filters
|
|
136
|
+
`Janela::Frame` by an owner, a frame created through Janela's own form
|
|
137
|
+
would be saved with no owner and hidden by that scope the instant it
|
|
138
|
+
was saved. Define the hook on the controller Janela inherits from:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
class ApplicationController < ActionController::Base
|
|
142
|
+
def janela_frame_owner
|
|
143
|
+
Current.account # whatever your scope filters frames by
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`janela:doctor` reports this one for you: it asks your policy for a
|
|
149
|
+
scope and looks for an owner in it. `docs/multi-tenancy.md`, shipped
|
|
150
|
+
in the gem, has the wiring for Pundit, acts_as_tenant and CanCanCan.
|
|
151
|
+
|
|
152
|
+
**7. Link the stylesheet, if you have not.**
|
|
153
|
+
|
|
154
|
+
```erb
|
|
155
|
+
<%= stylesheet_link_tag "janela" %>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Optional. It is the grid and enough style to read a pane, and Janela's
|
|
159
|
+
own pages load it themselves either way.
|
|
160
|
+
|
|
161
|
+
**Numbers read differently, and you need do nothing.** A measure now
|
|
162
|
+
renders to the precision it means rather than to whatever the database
|
|
163
|
+
returned, so a sum of a `decimal(10, 2)` column that read as `375.0`
|
|
164
|
+
reads as `375.00`. Declare `precision:`, `prefix:` or `suffix:` on the
|
|
165
|
+
measure to say otherwise. Nothing is rounded before it is stored or
|
|
166
|
+
compared.
|
|
167
|
+
|
|
168
|
+
**Nothing else changed.** Pane URLs, the route helpers `pane_path` and
|
|
169
|
+
`snapshot_pane_path`, turbo frame ids, and the CSS hooks
|
|
170
|
+
`janela-pane`, `janela-chart`, `janela-value` and `janela-empty` are
|
|
171
|
+
all as they were. Your measures, dimensions and snapshots are
|
|
172
|
+
untouched.
|
|
@@ -4,11 +4,12 @@ import { Chart, registerables } from "chart.js"
|
|
|
4
4
|
Chart.register(...registerables)
|
|
5
5
|
|
|
6
6
|
// Renders one pane as a chart and turns a click on a bar into the same
|
|
7
|
-
// toggle event a table button emits, so the
|
|
8
|
-
//
|
|
9
|
-
// is destroyed on disconnect and rebuilt on connect.
|
|
7
|
+
// toggle event a table button emits, so the frame controller cannot tell the
|
|
8
|
+
// difference. Turbo replaces the turbo frame on every cross-filter, so the
|
|
9
|
+
// chart is destroyed on disconnect and rebuilt on connect.
|
|
10
10
|
export default class extends Controller {
|
|
11
|
-
static values = { type: String, labels: Array, values: Array, filters: Object, title: String,
|
|
11
|
+
static values = { type: String, labels: Array, values: Array, filters: Object, title: String,
|
|
12
|
+
selected: Array, formatted: Array }
|
|
12
13
|
|
|
13
14
|
connect() {
|
|
14
15
|
this.chart = new Chart(this.element, {
|
|
@@ -25,22 +26,34 @@ export default class extends Controller {
|
|
|
25
26
|
options: {
|
|
26
27
|
animation: false,
|
|
27
28
|
scales: { y: { beginAtZero: true } },
|
|
28
|
-
plugins: {
|
|
29
|
-
|
|
29
|
+
plugins: {
|
|
30
|
+
legend: { display: false },
|
|
31
|
+
// The server formatted every number for the table, so the tooltip
|
|
32
|
+
// reads the same string rather than formatting a second time here
|
|
33
|
+
// and disagreeing with the cell beside it.
|
|
34
|
+
tooltip: { callbacks: { label: (item) => this.formattedValue[item.dataIndex] ?? item.formattedValue } }
|
|
35
|
+
},
|
|
36
|
+
onClick: (event, elements) => {
|
|
30
37
|
if (elements.length === 0) return
|
|
31
38
|
const label = this.labelsValue[elements[0].index]
|
|
32
39
|
const [key, value] = this.filtersValue[String(label)] || []
|
|
33
|
-
|
|
40
|
+
// A custom event carries no modifier flags of its own, so the
|
|
41
|
+
// gesture is read here and passed on (ADR 024).
|
|
42
|
+
const additive = event.native?.ctrlKey === true || event.native?.metaKey === true
|
|
43
|
+
if (key) this.dispatch("toggle", { detail: { key, value, additive } })
|
|
34
44
|
}
|
|
35
45
|
}
|
|
36
46
|
})
|
|
37
47
|
}
|
|
38
48
|
|
|
39
|
-
// With nothing selected every bar is solid; with a selection only
|
|
49
|
+
// 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.
|
|
40
51
|
colours() {
|
|
41
|
-
const selected = this.selectedValue
|
|
52
|
+
const selected = this.selectedValue.map(String)
|
|
42
53
|
return this.labelsValue.map((label) =>
|
|
43
|
-
|
|
54
|
+
selected.length === 0 || selected.includes(String(label))
|
|
55
|
+
? "rgba(54, 162, 235, 0.9)"
|
|
56
|
+
: "rgba(54, 162, 235, 0.25)"
|
|
44
57
|
)
|
|
45
58
|
}
|
|
46
59
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Shared filter state for every pane in the frame. Clicking a value rewrites
|
|
4
|
+
// each turbo frame's src, and Turbo reloads one whenever its src changes, so
|
|
5
|
+
// cross-filtering needs no streams, no sockets and no state library.
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
static targets = ["pane"]
|
|
8
|
+
static values = { filters: Object }
|
|
9
|
+
|
|
10
|
+
initialize() {
|
|
11
|
+
this.supersede = this.supersede.bind(this)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A pane is a turbo frame that outlives its own contents, so the listener is
|
|
15
|
+
// attached once per frame rather than once per render.
|
|
16
|
+
paneTargetConnected(pane) {
|
|
17
|
+
pane.addEventListener("turbo:before-fetch-request", this.supersede)
|
|
18
|
+
// What the server already rendered this pane for is what Janela has asked
|
|
19
|
+
// for, so a later change is measured against it rather than against
|
|
20
|
+
// nothing.
|
|
21
|
+
pane.dataset.janelaAsked ||= this.urlFor(pane).href
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
paneTargetDisconnected(pane) {
|
|
25
|
+
pane.removeEventListener("turbo:before-fetch-request", this.supersede)
|
|
26
|
+
pane.janelaRequest?.abort()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The request for what Janela last asked for wins, and any other is
|
|
30
|
+
// cancelled before it can land. A pane's load can be in flight when a click
|
|
31
|
+
// asks it for something else, and Turbo renders whatever arrives, so an
|
|
32
|
+
// older answer could overwrite a newer one and leave the pane showing
|
|
33
|
+
// numbers for filters nobody has any more. Neither arrival order nor start
|
|
34
|
+
// order settles it: a lazy load can begin after a click and still be for the
|
|
35
|
+
// old address. What was asked for is the only honest rule (#33).
|
|
36
|
+
supersede(event) {
|
|
37
|
+
const pane = event.currentTarget
|
|
38
|
+
const options = event.detail?.fetchOptions
|
|
39
|
+
const url = event.detail?.url
|
|
40
|
+
if (!options || !url) return
|
|
41
|
+
|
|
42
|
+
const request = new AbortController()
|
|
43
|
+
options.signal?.addEventListener("abort", () => request.abort(), { once: true })
|
|
44
|
+
options.signal = request.signal
|
|
45
|
+
|
|
46
|
+
const asked = pane.dataset.janelaAsked
|
|
47
|
+
if (asked && new URL(url, window.location.origin).href !== asked) {
|
|
48
|
+
// Stale before it started. Cancel it, and make sure the pane still ends
|
|
49
|
+
// up fetching what was asked for, since this may have been its only load.
|
|
50
|
+
request.abort()
|
|
51
|
+
// Bounded, so an address spelled differently from the one Janela built
|
|
52
|
+
// cannot set a pane correcting itself forever.
|
|
53
|
+
const corrections = pane.janelaCorrectedFor === asked ? (pane.janelaCorrections || 0) : 0
|
|
54
|
+
if ((!pane.janelaRequest || pane.janelaRequestFor !== asked) && corrections < 2) {
|
|
55
|
+
pane.janelaCorrectedFor = asked
|
|
56
|
+
pane.janelaCorrections = corrections + 1
|
|
57
|
+
queueMicrotask(() => { pane.src === asked ? pane.reload() : (pane.src = asked) })
|
|
58
|
+
}
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
pane.janelaRequest?.abort()
|
|
63
|
+
pane.janelaRequest = request
|
|
64
|
+
pane.janelaRequestFor = asked
|
|
65
|
+
request.signal.addEventListener("abort", () => {
|
|
66
|
+
if (pane.janelaRequest === request) pane.janelaRequest = null
|
|
67
|
+
}, { once: true })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Table buttons send key/value as Stimulus action params; charts dispatch a
|
|
71
|
+
// custom event carrying them in detail. Either way it is one value being
|
|
72
|
+
// added to or taken out of a selection.
|
|
73
|
+
//
|
|
74
|
+
// A plain click selects that value alone, or clears the dimension if it was
|
|
75
|
+
// the only one selected. Ctrl or Cmd adds and removes while the rest stay,
|
|
76
|
+
// which is how every list in every operating system already behaves. The
|
|
77
|
+
// same event carries the same flags when Enter is pressed on a focused
|
|
78
|
+
// value, so the keyboard needs nothing of its own (ADR 024).
|
|
79
|
+
toggle(event) {
|
|
80
|
+
const { key, value } = { ...event.detail, ...event.params }
|
|
81
|
+
const additive = event.ctrlKey || event.metaKey || event.detail?.additive === true
|
|
82
|
+
const filters = { ...this.filtersValue }
|
|
83
|
+
const selected = this.valuesFor(filters, key).includes(String(value))
|
|
84
|
+
|
|
85
|
+
// The null group asks for rows that have nothing there, so it cannot be
|
|
86
|
+
// combined with a value: Ransack ands its conditions, and the pair matches
|
|
87
|
+
// no row at all. It is exclusive within its dimension instead.
|
|
88
|
+
this.clearDimension(filters, key)
|
|
89
|
+
|
|
90
|
+
if (key.endsWith("_null")) {
|
|
91
|
+
if (!selected) filters[key] = "1"
|
|
92
|
+
} else {
|
|
93
|
+
let values = additive ? this.valuesFor(this.filtersValue, key) : []
|
|
94
|
+
values = selected ? values.filter((each) => each !== String(value)) : [ ...values, String(value) ]
|
|
95
|
+
if (values.length) filters[key] = [ ...new Set(values) ].sort()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.filtersValue = filters
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
clear() {
|
|
102
|
+
if (Object.keys(this.filtersValue).length) this.filtersValue = {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Whatever is selected for one key, as a set of strings. A filter arrives as
|
|
106
|
+
// an array from a click and as a string from a hand written _eq link.
|
|
107
|
+
valuesFor(filters, key) {
|
|
108
|
+
const held = filters[key]
|
|
109
|
+
if (held === undefined) return []
|
|
110
|
+
|
|
111
|
+
return (Array.isArray(held) ? held : [ held ]).map(String)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Every filter Janela itself writes for the same dimension: the values, the
|
|
115
|
+
// null group, and an _eq that a shared link may still carry. A host's own
|
|
116
|
+
// q[...] filters use other predicates and are left alone (ADR 008).
|
|
117
|
+
clearDimension(filters, key) {
|
|
118
|
+
const base = key.replace(/_(in|null|eq)$/, "")
|
|
119
|
+
for (const suffix of [ "in", "null", "eq" ]) delete filters[`${base}_${suffix}`]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Stimulus calls this as the controller starts, with the filters the server
|
|
123
|
+
// rendered, so nothing has changed yet. A frame is already showing the right
|
|
124
|
+
// numbers, and a src assigned here would make Turbo fetch every pane and
|
|
125
|
+
// throw that first render away (ADR 014).
|
|
126
|
+
//
|
|
127
|
+
// Janela keeps its own record of what it last applied rather than trusting
|
|
128
|
+
// the previous value it is handed. On a page opened from a filtered link,
|
|
129
|
+
// Stimulus passes the default empty object as the previous value, not
|
|
130
|
+
// nothing, so a check on that alone reloaded every pane on connect, and the
|
|
131
|
+
// redundant load could land after a click and undo it (#33).
|
|
132
|
+
filtersValueChanged(filters) {
|
|
133
|
+
const applied = JSON.stringify(filters)
|
|
134
|
+
if (this.applied === undefined || this.applied === applied) {
|
|
135
|
+
this.applied = applied
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
this.applied = applied
|
|
139
|
+
|
|
140
|
+
this.paneTargets.forEach((pane) => {
|
|
141
|
+
const url = this.urlFor(pane)
|
|
142
|
+
|
|
143
|
+
// Janela's own record of what it last asked this pane for, rather than
|
|
144
|
+
// the pane's src. A lazy load that began before a click lands after it,
|
|
145
|
+
// and Turbo then puts the URL it fetched back on the frame while leaving
|
|
146
|
+
// the newer content in place, so src stops describing what is on screen.
|
|
147
|
+
// Reading it meant the next change that happened to match was skipped
|
|
148
|
+
// and the pane kept numbers nobody had asked for (#33).
|
|
149
|
+
if (pane.dataset.janelaAsked === url.href) return
|
|
150
|
+
|
|
151
|
+
pane.dataset.janelaAsked = url.href
|
|
152
|
+
pane.src = url.href
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
this.syncPageUrl()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The page URL carries the same q[...] the panes do, so a reload or a
|
|
159
|
+
// pasted link opens the frame filtered (ADR 008). Replaced rather than
|
|
160
|
+
// pushed: a click is not a place the back button should return to.
|
|
161
|
+
syncPageUrl() {
|
|
162
|
+
const url = new URL(window.location.href)
|
|
163
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
164
|
+
if (key.startsWith("q[")) url.searchParams.delete(key)
|
|
165
|
+
}
|
|
166
|
+
this.writeFilters(url)
|
|
167
|
+
if (url.href !== window.location.href) history.replaceState(history.state, "", url)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
urlFor(pane) {
|
|
171
|
+
const url = new URL(pane.dataset.janelaSrc, window.location.origin)
|
|
172
|
+
this.writeFilters(url)
|
|
173
|
+
return url
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Sorted, keys and values both, so the browser serialises a selection the
|
|
177
|
+
// same way every time and an unchanged src is never reloaded.
|
|
178
|
+
writeFilters(url) {
|
|
179
|
+
for (const key of Object.keys(this.filtersValue).sort()) {
|
|
180
|
+
const held = this.filtersValue[key]
|
|
181
|
+
if (Array.isArray(held)) {
|
|
182
|
+
for (const value of [ ...held ].sort()) url.searchParams.append(`q[${key}][]`, value)
|
|
183
|
+
} else {
|
|
184
|
+
url.searchParams.set(`q[${key}]`, held)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Draws vitral's leadlight and lets it answer the pointer: every node leans
|
|
4
|
+
// toward the cursor, hardest nearby and not at all far away, then settles back
|
|
5
|
+
// when it leaves. A window made of data, which is the only excuse for an
|
|
6
|
+
// effect on a page about dashboards.
|
|
7
|
+
//
|
|
8
|
+
// Optional in every sense. The theme is complete without it, the stylesheet
|
|
9
|
+
// carries a static lattice for anyone who never loads this, Janela's own pages
|
|
10
|
+
// never load it, and a visitor who has asked for less motion gets a still one.
|
|
11
|
+
export default class extends Controller {
|
|
12
|
+
static values = {
|
|
13
|
+
columns: { type: Number, default: 17 },
|
|
14
|
+
rows: { type: Number, default: 13 },
|
|
15
|
+
reach: { type: Number, default: 260 }, // how far the pull carries, in pixels
|
|
16
|
+
pull: { type: Number, default: 26 }, // how far a node nearest the cursor travels
|
|
17
|
+
overscan: { type: Number, default: 70 }, // how far past the window the glass is cut
|
|
18
|
+
near: { type: Number, default: 0.16 }, // how much of a scroll the lead takes
|
|
19
|
+
far: { type: Number, default: 0.06 } // and the light behind it, which is further away
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
connect() {
|
|
23
|
+
this.canvas = document.createElement("canvas")
|
|
24
|
+
this.canvas.className = "vitral-lattice"
|
|
25
|
+
this.canvas.setAttribute("aria-hidden", "true")
|
|
26
|
+
document.body.prepend(this.canvas)
|
|
27
|
+
this.pen = this.canvas.getContext("2d")
|
|
28
|
+
this.element.classList.add("vitral-live")
|
|
29
|
+
|
|
30
|
+
this.still = window.matchMedia("(prefers-reduced-motion: reduce)").matches ||
|
|
31
|
+
!window.matchMedia("(hover: hover)").matches
|
|
32
|
+
|
|
33
|
+
this.onResize = this.onResize.bind(this)
|
|
34
|
+
this.onMove = this.onMove.bind(this)
|
|
35
|
+
this.onLeave = this.onLeave.bind(this)
|
|
36
|
+
this.onScroll = this.onScroll.bind(this)
|
|
37
|
+
|
|
38
|
+
// Decoration waits its turn. Cutting the glass is a few hundred polygons,
|
|
39
|
+
// and a dashboard's first paint and its frames matter more than a
|
|
40
|
+
// background does, so the first draw happens once the browser is idle.
|
|
41
|
+
this.idle = window.requestIdleCallback
|
|
42
|
+
? requestIdleCallback(() => this.cut(), { timeout: 600 })
|
|
43
|
+
: setTimeout(() => this.cut(), 120)
|
|
44
|
+
|
|
45
|
+
window.addEventListener("resize", this.onResize, { passive: true })
|
|
46
|
+
if (!this.still) {
|
|
47
|
+
window.addEventListener("scroll", this.onScroll, { passive: true })
|
|
48
|
+
this.onScroll()
|
|
49
|
+
window.addEventListener("pointermove", this.onMove, { passive: true })
|
|
50
|
+
document.addEventListener("pointerleave", this.onLeave)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
disconnect() {
|
|
55
|
+
window.removeEventListener("resize", this.onResize)
|
|
56
|
+
window.removeEventListener("scroll", this.onScroll)
|
|
57
|
+
window.removeEventListener("pointermove", this.onMove)
|
|
58
|
+
document.removeEventListener("pointerleave", this.onLeave)
|
|
59
|
+
cancelAnimationFrame(this.frame)
|
|
60
|
+
cancelAnimationFrame(this.drifting)
|
|
61
|
+
if (window.cancelIdleCallback && this.idle) cancelIdleCallback(this.idle)
|
|
62
|
+
clearTimeout(this.resizing)
|
|
63
|
+
this.canvas?.remove()
|
|
64
|
+
this.element.classList.remove("vitral-live")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Cutting the glass: a jittered grid of nodes, then cells over them, some
|
|
68
|
+
// cut across the diagonal the way a real window is. Seeded, so the same
|
|
69
|
+
// window is cut every time rather than a new one on every load.
|
|
70
|
+
cut() {
|
|
71
|
+
const random = seeded(1912)
|
|
72
|
+
const page = document.documentElement.scrollHeight - window.innerHeight
|
|
73
|
+
this.slack = this.still ? 0 : Math.min(600, Math.ceil(page * this.nearValue) + 20)
|
|
74
|
+
|
|
75
|
+
const w = window.innerWidth
|
|
76
|
+
const h = window.innerHeight + this.slack * 2
|
|
77
|
+
this.canvas.width = Math.ceil(w * devicePixelRatio)
|
|
78
|
+
this.canvas.height = Math.ceil(h * devicePixelRatio)
|
|
79
|
+
this.canvas.style.width = `${w}px`
|
|
80
|
+
this.canvas.style.height = `${h}px`
|
|
81
|
+
this.canvas.style.top = `${-this.slack}px`
|
|
82
|
+
this.canvas.style.bottom = "auto"
|
|
83
|
+
this.pen.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
|
|
84
|
+
this.paper = [ w, h ]
|
|
85
|
+
|
|
86
|
+
const cols = this.columnsValue
|
|
87
|
+
// Taller glass, same size cells, so the pattern does not stretch.
|
|
88
|
+
const rows = Math.max(3, Math.round(this.rowsValue * h / window.innerHeight))
|
|
89
|
+
|
|
90
|
+
// Cut wider than the window, and pin the outermost ring. A node that can
|
|
91
|
+
// be dragged off the edge takes the glass with it and leaves a bare gap
|
|
92
|
+
// there, so the outer ring is held and lives off screen besides. The
|
|
93
|
+
// window flexes; its frame does not.
|
|
94
|
+
const pad = this.overscanValue
|
|
95
|
+
const cw = (w + pad * 2) / cols, ch = (h + pad * 2) / rows
|
|
96
|
+
|
|
97
|
+
this.nodes = []
|
|
98
|
+
const index = (r, c) => r * (cols + 1) + c
|
|
99
|
+
for (let r = 0; r <= rows; r++) {
|
|
100
|
+
for (let c = 0; c <= cols; c++) {
|
|
101
|
+
const pinned = c === 0 || c === cols || r === 0 || r === rows
|
|
102
|
+
const x = -pad + c * cw + (pinned ? 0 : (random() - 0.5) * 0.64 * cw)
|
|
103
|
+
const y = -pad + r * ch + (pinned ? 0 : (random() - 0.5) * 0.64 * ch)
|
|
104
|
+
this.nodes.push({ home: [ x, y ], at: [ x, y ], pinned })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const tints = [ "58,122,235", "28,168,158", "240,176,70", "224,96,130", "138,110,228" ]
|
|
109
|
+
this.cells = []
|
|
110
|
+
for (let r = 0; r < rows; r++) {
|
|
111
|
+
for (let c = 0; c < cols; c++) {
|
|
112
|
+
const a = index(r, c), b = index(r, c + 1), d = index(r + 1, c + 1), e = index(r + 1, c)
|
|
113
|
+
const quads = random() < 0.45
|
|
114
|
+
? (random() < 0.5 ? [ [ a, b, d ], [ a, d, e ] ] : [ [ a, b, e ], [ b, d, e ] ])
|
|
115
|
+
: [ [ a, b, d, e ] ]
|
|
116
|
+
for (const corners of quads) {
|
|
117
|
+
const tint = random() < 0.3
|
|
118
|
+
? `rgba(${tints[Math.floor(random() * tints.length)]}, ${(0.025 + random() * 0.035).toFixed(3)})`
|
|
119
|
+
: null
|
|
120
|
+
this.cells.push({ corners, tint })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.draw()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
onResize() {
|
|
129
|
+
clearTimeout(this.resizing)
|
|
130
|
+
this.resizing = setTimeout(() => this.cut(), 150)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Both background layers move with the page, and neither keeps up with it.
|
|
134
|
+
// What is near slides past, what is far barely shifts, which is what makes
|
|
135
|
+
// the window read as a window rather than as wallpaper.
|
|
136
|
+
onScroll() {
|
|
137
|
+
if (this.drifting) return
|
|
138
|
+
|
|
139
|
+
this.drifting = requestAnimationFrame(() => {
|
|
140
|
+
this.drifting = null
|
|
141
|
+
const scrolled = window.scrollY
|
|
142
|
+
|
|
143
|
+
// Each layer is clamped to the slack it actually has, or it drifts past
|
|
144
|
+
// its own edge and shows the bare ground behind it on a long page. The
|
|
145
|
+
// lead's slack is how much taller than the window it was cut; the
|
|
146
|
+
// light's is the 20vmax it hangs outside the window by.
|
|
147
|
+
const lead = this.slack ?? 0
|
|
148
|
+
const light = 0.2 * Math.max(window.innerWidth, window.innerHeight)
|
|
149
|
+
|
|
150
|
+
this.element.style.setProperty("--vitral-drift-near", `${clamp(-scrolled * this.nearValue, lead)}px`)
|
|
151
|
+
this.element.style.setProperty("--vitral-drift-far", `${clamp(-scrolled * this.farValue, light)}px`)
|
|
152
|
+
|
|
153
|
+
// The glass has just slid under a cursor that has not moved, so the
|
|
154
|
+
// nodes have to take aim again or they stay reaching for the place the
|
|
155
|
+
// cursor used to be.
|
|
156
|
+
if (this.aim) this.start()
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
onMove(event) {
|
|
161
|
+
// Kept in screen coordinates and converted when the frame is drawn. The
|
|
162
|
+
// glass is cut taller than the window and drifts as the page scrolls, so
|
|
163
|
+
// where the cursor is on screen is not where it is on the canvas, and
|
|
164
|
+
// converting here would mean reading layout on every pointer event.
|
|
165
|
+
this.aim = [ event.clientX, event.clientY ]
|
|
166
|
+
this.element.style.setProperty("--vitral-shift-x", (event.clientX / window.innerWidth - 0.5).toFixed(3))
|
|
167
|
+
this.element.style.setProperty("--vitral-shift-y", (event.clientY / window.innerHeight - 0.5).toFixed(3))
|
|
168
|
+
this.start()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
onLeave() {
|
|
172
|
+
this.aim = null
|
|
173
|
+
this.start()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
start() {
|
|
177
|
+
if (this.frame) return
|
|
178
|
+
this.frame = requestAnimationFrame(() => this.step())
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Each node eases toward where the pointer wants it, so the glass leans and
|
|
182
|
+
// settles rather than snapping. The loop stops once nothing is moving,
|
|
183
|
+
// because a background that animates forever is a laptop fan.
|
|
184
|
+
step() {
|
|
185
|
+
this.frame = null
|
|
186
|
+
if (!this.nodes) return
|
|
187
|
+
|
|
188
|
+
// One layout read per frame rather than one per pointer event. The rect
|
|
189
|
+
// accounts for the overscan, the scroll drift and the pointer parallax in
|
|
190
|
+
// a single measurement, so the pull lands where the cursor actually is.
|
|
191
|
+
const pointer = this.aimOnGlass()
|
|
192
|
+
let moving = false
|
|
193
|
+
|
|
194
|
+
for (const node of this.nodes) {
|
|
195
|
+
const [ hx, hy ] = node.home
|
|
196
|
+
let tx = hx, ty = hy
|
|
197
|
+
|
|
198
|
+
if (pointer && !node.pinned) {
|
|
199
|
+
const dx = pointer[0] - hx, dy = pointer[1] - hy
|
|
200
|
+
const distance = Math.hypot(dx, dy)
|
|
201
|
+
const force = Math.exp(-((distance / this.reachValue) ** 2))
|
|
202
|
+
if (distance > 0.5) {
|
|
203
|
+
tx = hx + (dx / distance) * this.pullValue * force
|
|
204
|
+
ty = hy + (dy / distance) * this.pullValue * force
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
node.at[0] += (tx - node.at[0]) * 0.16
|
|
209
|
+
node.at[1] += (ty - node.at[1]) * 0.16
|
|
210
|
+
if (Math.abs(tx - node.at[0]) > 0.08 || Math.abs(ty - node.at[1]) > 0.08) moving = true
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
this.draw()
|
|
214
|
+
if (moving) this.start()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
aimOnGlass() {
|
|
218
|
+
if (!this.aim) return null
|
|
219
|
+
|
|
220
|
+
const rect = this.canvas.getBoundingClientRect()
|
|
221
|
+
return [ this.aim[0] - rect.left, this.aim[1] - rect.top ]
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
draw() {
|
|
225
|
+
const pen = this.pen
|
|
226
|
+
pen.clearRect(0, 0, this.paper[0], this.paper[1])
|
|
227
|
+
pen.lineWidth = 1
|
|
228
|
+
pen.lineJoin = "round"
|
|
229
|
+
// The stylesheet owns the colour, so a retheme is one property and the
|
|
230
|
+
// live lattice never disagrees with the static one.
|
|
231
|
+
pen.strokeStyle = this.ink ||= getComputedStyle(this.element)
|
|
232
|
+
.getPropertyValue("--vitral-lattice-ink").trim() || "rgba(44, 62, 84, 0.11)"
|
|
233
|
+
|
|
234
|
+
for (const cell of this.cells) {
|
|
235
|
+
pen.beginPath()
|
|
236
|
+
cell.corners.forEach((corner, position) => {
|
|
237
|
+
const [ x, y ] = this.nodes[corner].at
|
|
238
|
+
position === 0 ? pen.moveTo(x, y) : pen.lineTo(x, y)
|
|
239
|
+
})
|
|
240
|
+
pen.closePath()
|
|
241
|
+
if (cell.tint) {
|
|
242
|
+
pen.fillStyle = cell.tint
|
|
243
|
+
pen.fill()
|
|
244
|
+
}
|
|
245
|
+
pen.stroke()
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function clamp(value, limit) {
|
|
251
|
+
return Math.max(-limit, Math.min(limit, value)).toFixed(1)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Mulberry32: small, seeded, and good enough to cut glass with.
|
|
255
|
+
function seeded(seed) {
|
|
256
|
+
return function () {
|
|
257
|
+
seed |= 0
|
|
258
|
+
seed = seed + 0x6D2B79F5 | 0
|
|
259
|
+
let t = Math.imul(seed ^ seed >>> 15, 1 | seed)
|
|
260
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t
|
|
261
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296
|
|
262
|
+
}
|
|
263
|
+
}
|