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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -1
  3. data/LICENSE.txt +1 -1
  4. data/README.md +244 -23
  5. data/UPGRADING.md +172 -0
  6. data/app/assets/javascripts/janela/chart_controller.js +23 -10
  7. data/app/assets/javascripts/janela/frame_controller.js +188 -0
  8. data/app/assets/javascripts/janela/vitral_controller.js +263 -0
  9. data/app/assets/stylesheets/janela.css +164 -0
  10. data/app/assets/stylesheets/vitral.css +343 -0
  11. data/app/controllers/janela/application_controller.rb +25 -0
  12. data/app/controllers/janela/frames_controller.rb +57 -0
  13. data/app/controllers/janela/panes_controller.rb +66 -13
  14. data/app/controllers/janela/queries_controller.rb +17 -0
  15. data/app/controllers/janela/{snapshot_panes_controller.rb → snapshot_queries_controller.rb} +7 -5
  16. data/app/helpers/janela/{dashboard_helper.rb → frames_helper.rb} +26 -9
  17. data/app/models/janela/frame.rb +28 -0
  18. data/app/models/janela/pane.rb +102 -99
  19. data/app/models/janela/query.rb +162 -0
  20. data/app/models/janela/snapshot.rb +5 -5
  21. data/app/views/janela/frames/_card.html.erb +7 -0
  22. data/app/views/janela/frames/_form.html.erb +23 -0
  23. data/app/views/janela/frames/_frame.html.erb +5 -0
  24. data/app/views/janela/frames/_pane.html.erb +12 -0
  25. data/app/views/janela/frames/edit.html.erb +25 -0
  26. data/app/views/janela/frames/index.html.erb +15 -0
  27. data/app/views/janela/frames/new.html.erb +5 -0
  28. data/app/views/janela/frames/show.html.erb +9 -0
  29. data/app/views/janela/panes/_form.html.erb +58 -0
  30. data/app/views/janela/panes/_row.html.erb +12 -0
  31. data/app/views/janela/panes/edit.html.erb +5 -0
  32. data/app/views/janela/panes/new.html.erb +22 -0
  33. data/app/views/janela/panes/show.html.erb +3 -46
  34. data/app/views/janela/queries/_query.html.erb +47 -0
  35. data/app/views/janela/queries/show.html.erb +4 -0
  36. data/app/views/janela/shared/_errors.html.erb +7 -0
  37. data/app/views/layouts/janela/application.html.erb +22 -5
  38. data/config/importmap.rb +2 -1
  39. data/config/locales/en.yml +65 -0
  40. data/config/routes.rb +15 -2
  41. data/db/migrate/20260916000001_create_janela_frames.rb +13 -0
  42. data/db/migrate/20260916000002_create_janela_panes.rb +22 -0
  43. data/docs/decisions/001-built-to-be-forked.md +4 -0
  44. data/docs/decisions/009-snapshots.md +5 -2
  45. data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +8 -4
  46. data/docs/decisions/012-frames-and-panes-are-data.md +166 -0
  47. data/docs/decisions/013-naming-and-addressing-frames.md +119 -0
  48. data/docs/decisions/014-corrections-before-frames-are-built.md +222 -0
  49. data/docs/decisions/015-how-breaking-change-is-communicated.md +114 -0
  50. data/docs/decisions/016-the-styling-vocabulary.md +100 -0
  51. data/docs/decisions/017-janela-owns-no-data-store.md +113 -0
  52. data/docs/decisions/018-a-table-is-the-universal-renderer.md +75 -0
  53. data/docs/decisions/019-a-created-frame-asks-the-host-who-owns-it.md +79 -0
  54. data/docs/decisions/020-formatting-belongs-to-the-measure.md +93 -0
  55. data/docs/decisions/021-a-check-has-a-name-a-host-can-silence.md +84 -0
  56. data/docs/decisions/022-host-route-helpers-work-inside-the-engine.md +104 -0
  57. data/docs/decisions/023-vitral-is-a-theme-not-the-stylesheet.md +86 -0
  58. data/docs/decisions/024-selecting-more-than-one-value.md +127 -0
  59. data/docs/decisions/INDEX.md +25 -7
  60. data/docs/multi-tenancy.md +175 -0
  61. data/docs/naming.md +172 -0
  62. data/lib/janela/definition.rb +5 -5
  63. data/lib/janela/doctor.rb +219 -0
  64. data/lib/janela/engine.rb +24 -2
  65. data/lib/janela/host_routes.rb +31 -0
  66. data/lib/janela/measure.rb +65 -4
  67. data/lib/janela/version.rb +1 -1
  68. data/lib/janela.rb +24 -0
  69. data/lib/tasks/janela.rake +6 -0
  70. metadata +57 -4
  71. 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 dashboard controller cannot tell
8
- // the difference. Turbo replaces the frame on every cross-filter, so the chart
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, selected: 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: { legend: { display: false } },
29
- onClick: (_event, elements) => {
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
- if (key) this.dispatch("toggle", { detail: { key, value } })
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 that bar is.
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
- !selected || label === selected ? "rgba(54, 162, 235, 0.9)" : "rgba(54, 162, 235, 0.25)"
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
+ }