janela 0.3.0 → 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.
@@ -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
+ }
@@ -97,6 +97,7 @@ canvas.janela-chart { width: 100% !important; max-height: 20rem; }
97
97
  }
98
98
  .janela-heading { margin: 0 0 calc(var(--janela-space) * 2); font-size: 1.5rem; }
99
99
  .janela-crumb { margin: 0 0 calc(var(--janela-space) * 6); font-size: 0.875rem; opacity: 0.7; }
100
+ .janela-exit { margin: 0 0 calc(var(--janela-space) * 6); font-size: 0.875rem; opacity: 0.7; }
100
101
  .janela-card {
101
102
  display: flex;
102
103
  flex-direction: column;