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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +1 -1
- data/README.md +47 -6
- data/UPGRADING.md +37 -1
- data/app/assets/javascripts/janela/chart_controller.js +12 -6
- data/app/assets/javascripts/janela/frame_controller.js +140 -17
- data/app/assets/javascripts/janela/vitral_controller.js +263 -0
- data/app/assets/stylesheets/janela.css +1 -0
- data/app/assets/stylesheets/vitral.css +343 -0
- data/app/controllers/janela/application_controller.rb +7 -0
- data/app/helpers/janela/frames_helper.rb +2 -1
- data/app/models/janela/query.rb +18 -5
- data/app/views/janela/frames/_frame.html.erb +2 -1
- data/app/views/janela/queries/_query.html.erb +2 -2
- data/app/views/layouts/janela/application.html.erb +10 -1
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +1 -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 +4 -1
- data/docs/naming.md +172 -0
- data/lib/janela/engine.rb +10 -1
- data/lib/janela/host_routes.rb +31 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +7 -0
- metadata +13 -10
|
@@ -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;
|