@bakery-framework/plugin-dashboard 2.0.0-alpha.11 → 2.0.0-alpha.13
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.
- package/package.json +6 -4
- package/src/client/dashboard.ts +1 -8
- package/src/client/parts/logs.ts +9 -10
- package/src/client/parts/metrics.ts +251 -0
- package/src/client/parts/sessions.ts +26 -124
- package/src/client/parts/sparkline-tooltip.ts +158 -0
- package/src/client/parts/sparkline.ts +383 -0
- package/src/client/parts/stats.ts +33 -758
- package/src/client/parts/utils.ts +18 -64
- package/src/components/DBBrowser.tsx +0 -2
- package/src/components/LogsPanel.tsx +0 -2
- package/src/components/SessionsPanel.tsx +0 -3
- package/src/components/StatsPanel.tsx +0 -19
- package/src/components/TopPagesPanel.tsx +0 -1
- package/src/setup.ts +63 -2
- package/src/shell.tsx +35 -2
- package/src/client/parts/effects.ts +0 -327
|
@@ -31,7 +31,7 @@ export function setText(id: string, value: string) {
|
|
|
31
31
|
* stats panels build markup the same way from data they did not write.
|
|
32
32
|
*/
|
|
33
33
|
export function emptyBox(message: string, isError = false): string {
|
|
34
|
-
const style = isError ? ' style="color: var(--
|
|
34
|
+
const style = isError ? ' style="color: var(--danger);"' : ''
|
|
35
35
|
return `<div class="results-empty"${style}><span>${escapeHTML(message)}</span></div>`
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -57,8 +57,9 @@ export function icon(d: string, size: string): string {
|
|
|
57
57
|
)
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// `ICON_EDIT` was here. It drew the pencil on the session key editor's Edit
|
|
61
|
+
// button, and that button went with the editor it could not open — see the
|
|
62
|
+
// note in `sessions.ts`. The path data has no other caller.
|
|
62
63
|
export const ICON_DELETE =
|
|
63
64
|
'M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zM17 6H7v13h10zM9 17h2V8H9zm4 0h2V8h-2zM7 6v13z'
|
|
64
65
|
/** POST a JSON body to a dashboard endpoint and unwrap the envelope. */
|
|
@@ -84,67 +85,20 @@ export function setPager(
|
|
|
84
85
|
if (nextBtn) nextBtn.disabled = page >= totalPages
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
this.percent = percent
|
|
102
|
-
this.barWidth = barWidth
|
|
103
|
-
this.barGap = barGap
|
|
104
|
-
this.init()
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
private init() {
|
|
108
|
-
this.container.classList.add('segmented-progress-container')
|
|
109
|
-
if (this.barGap !== 6) {
|
|
110
|
-
this.container.style.gap = `${this.barGap}px`
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (typeof ResizeObserver !== 'undefined') {
|
|
114
|
-
this.resizeObserver = new ResizeObserver(() => this.draw())
|
|
115
|
-
this.resizeObserver.observe(this.container)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
this.draw()
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
public destroy() {
|
|
122
|
-
if (this.resizeObserver) {
|
|
123
|
-
this.resizeObserver.disconnect()
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
public draw() {
|
|
128
|
-
const containerWidth = this.container.clientWidth
|
|
129
|
-
if (containerWidth === 0) return
|
|
130
|
-
|
|
131
|
-
const count = Math.floor(
|
|
132
|
-
(containerWidth + this.barGap) / (this.barWidth + this.barGap),
|
|
133
|
-
)
|
|
134
|
-
const activeCount = Math.round((this.percent / 100) * count)
|
|
135
|
-
|
|
136
|
-
let html = ''
|
|
137
|
-
for (let i = 0; i < count; i++) {
|
|
138
|
-
const className = i < activeCount ? 'active' : 'inactive'
|
|
139
|
-
let styleAttr = ''
|
|
140
|
-
if (this.barWidth !== 4) {
|
|
141
|
-
styleAttr = ` style="width: ${this.barWidth}px;"`
|
|
142
|
-
}
|
|
143
|
-
html += `<div class="segmented-bar-segment ${className}"${styleAttr}></div>`
|
|
144
|
-
}
|
|
145
|
-
this.container.innerHTML = html
|
|
146
|
-
}
|
|
147
|
-
}
|
|
88
|
+
/*
|
|
89
|
+
* `SegmentedProgress` was here, and nothing it drew was ever visible.
|
|
90
|
+
*
|
|
91
|
+
* It filled a `.segmented-progress-container` with one `div` per 10px of
|
|
92
|
+
* width and watched the container with a `ResizeObserver`. Neither that
|
|
93
|
+
* class nor `.segmented-bar-segment` has a rule in the sheet - grep it - so
|
|
94
|
+
* the container is a bare block with no height and the segments are bare
|
|
95
|
+
* blocks with no width. Measured against a running console on the Traffic
|
|
96
|
+
* panel: 10 containers, 98 children each, every one of them 0px tall.
|
|
97
|
+
*
|
|
98
|
+
* So it cost 980 nodes and 10 `ResizeObserver`s per render of that panel,
|
|
99
|
+
* redrawn on every resize, to show nothing. The list it sat under - path and
|
|
100
|
+
* hit count - works and stays.
|
|
101
|
+
*/
|
|
148
102
|
|
|
149
103
|
export function getWebSocketUrl(path: string) {
|
|
150
104
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
@@ -24,7 +24,6 @@ export function renderDatabaseBrowser() {
|
|
|
24
24
|
<div class="browser-header">
|
|
25
25
|
<div class="table-info">
|
|
26
26
|
<h2>
|
|
27
|
-
<iconify-icon icon="lucide:database"></iconify-icon>
|
|
28
27
|
<span>Database</span>
|
|
29
28
|
</h2>
|
|
30
29
|
</div>
|
|
@@ -43,7 +42,6 @@ export function renderDatabaseBrowser() {
|
|
|
43
42
|
</p>
|
|
44
43
|
<div class="modal-actions">
|
|
45
44
|
<a class="btn btn-primary" href="/_db">
|
|
46
|
-
<iconify-icon icon="lucide:external-link"></iconify-icon>
|
|
47
45
|
<span>Open Database Explorer</span>
|
|
48
46
|
</a>
|
|
49
47
|
</div>
|
|
@@ -10,14 +10,12 @@ export function renderLogsPanel() {
|
|
|
10
10
|
class="btn btn-secondary"
|
|
11
11
|
onclick="toggleLogsPlay()"
|
|
12
12
|
id="btn-logs-play">
|
|
13
|
-
<iconify-icon icon="lucide:pause"></iconify-icon>
|
|
14
13
|
<span>Pause</span>
|
|
15
14
|
</button>
|
|
16
15
|
<button
|
|
17
16
|
type="button"
|
|
18
17
|
class="btn btn-secondary btn-danger"
|
|
19
18
|
onclick="clearLogs()">
|
|
20
|
-
<iconify-icon icon="lucide:trash-2"></iconify-icon>
|
|
21
19
|
<span>Clear Logs</span>
|
|
22
20
|
</button>
|
|
23
21
|
<label>
|
|
@@ -13,7 +13,6 @@ export function renderSessionsPanel() {
|
|
|
13
13
|
type="button"
|
|
14
14
|
class="btn btn-secondary"
|
|
15
15
|
onclick="loadSessions()">
|
|
16
|
-
<iconify-icon icon="lucide:refresh-cw"></iconify-icon>
|
|
17
16
|
<span>Refresh Sessions</span>
|
|
18
17
|
</button>
|
|
19
18
|
</div>
|
|
@@ -62,7 +61,6 @@ export function renderSessionsPanel() {
|
|
|
62
61
|
id="session-page-prev"
|
|
63
62
|
class="btn btn-secondary"
|
|
64
63
|
onclick="prevSessionPage()">
|
|
65
|
-
<iconify-icon icon="lucide:chevron-left"></iconify-icon>
|
|
66
64
|
<span>Prev</span>
|
|
67
65
|
</button>
|
|
68
66
|
<span id="session-page-info">Page 1 of 1</span>
|
|
@@ -72,7 +70,6 @@ export function renderSessionsPanel() {
|
|
|
72
70
|
class="btn btn-secondary"
|
|
73
71
|
onclick="nextSessionPage()">
|
|
74
72
|
<span>Next</span>
|
|
75
|
-
<iconify-icon icon="lucide:chevron-right"></iconify-icon>
|
|
76
73
|
</button>
|
|
77
74
|
</div>
|
|
78
75
|
<div class="rows-meta">
|
|
@@ -54,7 +54,6 @@ export function renderStatsPanel() {
|
|
|
54
54
|
|
|
55
55
|
<div class="section-header">
|
|
56
56
|
<h2>
|
|
57
|
-
<iconify-icon icon="lucide:area-chart"></iconify-icon>
|
|
58
57
|
<span>Performance History</span>
|
|
59
58
|
</h2>
|
|
60
59
|
<div class="timescale-selector">
|
|
@@ -223,24 +222,6 @@ export function renderStatsPanel() {
|
|
|
223
222
|
</span>
|
|
224
223
|
</div>
|
|
225
224
|
</div>
|
|
226
|
-
<div class="chart-card glass-effect" id="chart-db-hits">
|
|
227
|
-
<span class="card-title">DB Hits History</span>
|
|
228
|
-
<span class="card-sub">
|
|
229
|
-
Database query executions (last 1 min, 1s resolution)
|
|
230
|
-
</span>
|
|
231
|
-
<canvas id="canvas-db-hits" class="big-chart"></canvas>
|
|
232
|
-
<div class="chart-stats">
|
|
233
|
-
<span>
|
|
234
|
-
MIN: <strong id="dbHits-min">-</strong>
|
|
235
|
-
</span>
|
|
236
|
-
<span>
|
|
237
|
-
MAX: <strong id="dbHits-max">-</strong>
|
|
238
|
-
</span>
|
|
239
|
-
<span>
|
|
240
|
-
AVG: <strong id="dbHits-avg">-</strong>
|
|
241
|
-
</span>
|
|
242
|
-
</div>
|
|
243
|
-
</div>
|
|
244
225
|
<div class="chart-card glass-effect" id="chart-error-page-hits">
|
|
245
226
|
<span class="card-title">Error Page Hits History</span>
|
|
246
227
|
<span class="card-sub">
|
package/src/setup.ts
CHANGED
|
@@ -139,10 +139,58 @@ async function handleDashboardView() {
|
|
|
139
139
|
return injected ? injected : response.html(htmlContent)
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
/**
|
|
143
|
+
* A bare `import` left in the bundle, or `null`.
|
|
144
|
+
*
|
|
145
|
+
* `bundleModule` marks every *installed* package external, so a bare specifier
|
|
146
|
+
* survives into the output and resolves through the page's import map. That is
|
|
147
|
+
* correct for the `/_nm/` bundles, which are loaded as modules. It is fatal
|
|
148
|
+
* here: this file is served as a classic `<script>`, an import map does not
|
|
149
|
+
* apply to one, and a top-level `import` is a syntax error — so the *entire*
|
|
150
|
+
* bundle fails to execute and the console does nothing at all.
|
|
151
|
+
*
|
|
152
|
+
* It is worth a check rather than a convention because of how it failed.
|
|
153
|
+
* `Bun.build` reported success, the file was served with a 200 and the right
|
|
154
|
+
* length, typecheck passed, and 2,376 tests passed — because nothing
|
|
155
|
+
* *requested the page*. That is the same gap CLAUDE.md already records for
|
|
156
|
+
* `apps/starter`: a process that boots is not a page that works.
|
|
157
|
+
*
|
|
158
|
+
* Matched on a line starting with `import`, which is what an ESM bundle emits
|
|
159
|
+
* for an external. A dynamic `import(...)` inside a function body is fine and
|
|
160
|
+
* does not start a line.
|
|
161
|
+
*/
|
|
162
|
+
export function bareImportIn(bundle: string): string | null {
|
|
163
|
+
const match = bundle.match(/^import\s[^\n]*?from\s*['"]([^'"]+)['"]/m)
|
|
164
|
+
return match ? match[1]! : null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Test seams (convention 9). The cached branch is **production-only** -
|
|
169
|
+
* `isDevWorker` skips it, and in development the bundle is returned as a
|
|
170
|
+
* string, which `response.type` does give an ETag. That is why the defect was
|
|
171
|
+
* invisible to a dev server and had to be reproduced under `bun run serve`,
|
|
172
|
+
* and why a test has to reach the branch directly rather than through a mode.
|
|
173
|
+
*/
|
|
174
|
+
export function __setCachedDashboardJs(path: string | null): void {
|
|
175
|
+
cachedDashboardJsPath = path
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function handleJsAsset() {
|
|
143
179
|
if (cachedDashboardJsPath && !isDevWorker) {
|
|
144
180
|
const cachedFile = Bun.file(cachedDashboardJsPath)
|
|
145
|
-
|
|
181
|
+
// **The file, not a `Response` wrapping it.** `processResponse` sends a
|
|
182
|
+
// `Blob` through `ETag.sendFile`, which computes an ETag from the file,
|
|
183
|
+
// negotiates a precompressed variant and answers a conditional request
|
|
184
|
+
// with a 304. A `Response` skips all of that: `ETag.sendResponse` returns
|
|
185
|
+
// early when there is no ETag header already set, so it gains neither an
|
|
186
|
+
// ETag nor a `Cache-Control`.
|
|
187
|
+
//
|
|
188
|
+
// The first request took this path correctly - it returns the written
|
|
189
|
+
// file below - and every request after it did not, so the console
|
|
190
|
+
// re-downloaded 30.6 KB on every load. The content type comes from the
|
|
191
|
+
// `.js` extension on the cached path, which is what the first request
|
|
192
|
+
// already relied on.
|
|
193
|
+
if (cachedFile.size > 0) return cachedFile
|
|
146
194
|
}
|
|
147
195
|
|
|
148
196
|
const bundleResult = await bundleModule(pluginPath('client/dashboard.ts'))
|
|
@@ -157,6 +205,19 @@ async function handleJsAsset() {
|
|
|
157
205
|
)
|
|
158
206
|
}
|
|
159
207
|
|
|
208
|
+
// See `bareImportIn`. A build that "succeeded" and cannot run is worse than
|
|
209
|
+
// one that failed, because it is silent.
|
|
210
|
+
const bare = bareImportIn(bundleResult.content as string)
|
|
211
|
+
if (bare) {
|
|
212
|
+
pluginLog.DASHBOARD_BUNDLE_ERR({
|
|
213
|
+
error: `dashboard.js kept a bare import of "${bare}". It is served as a classic script, so an import map cannot resolve it and the whole bundle fails to parse. The console client must not import across a package boundary.`,
|
|
214
|
+
})
|
|
215
|
+
return response.error(
|
|
216
|
+
`dashboard.js kept a bare import of "${bare}"`,
|
|
217
|
+
500,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
160
221
|
if (!isDevWorker) {
|
|
161
222
|
const tmpPath = `${Bakery.cacheDir}/_dashboard.js`
|
|
162
223
|
await Bun.write(tmpPath, bundleResult.content)
|
package/src/shell.tsx
CHANGED
|
@@ -95,11 +95,37 @@ function NavItem({ id, label, href }: NavEntry) {
|
|
|
95
95
|
)
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The rendered shell, and the one fact it depends on.
|
|
100
|
+
*
|
|
101
|
+
* Everything in this document is fixed for the life of the process except
|
|
102
|
+
* whether an explorer is mounted at `/_db`, which decides one nav entry, and
|
|
103
|
+
* the framework version, which cannot change at all. Rendering it again per
|
|
104
|
+
* request produced an identical 11.8 KB string every time.
|
|
105
|
+
*
|
|
106
|
+
* Measured at **0.50 ms per render** with a CPU-bound control flat at 27-30 ms
|
|
107
|
+
* - the backlog recorded 12.6 ms, which is 25 times the figure this actually
|
|
108
|
+
* measures. Small, then, and still pure waste on every console page load.
|
|
109
|
+
*
|
|
110
|
+
* Keyed on the mounted flag rather than held unconditionally, so a registry
|
|
111
|
+
* that changes after the first render is followed rather than remembered
|
|
112
|
+
* wrongly. Two entries at most, which is why a plain pair is enough and
|
|
113
|
+
* convention 6 has nothing to say about it.
|
|
114
|
+
*/
|
|
115
|
+
let shellCache: { mounted: boolean; html: string } | null = null
|
|
116
|
+
|
|
117
|
+
/** Test seam (convention 9): one test's shell must not answer for another's. */
|
|
118
|
+
export function __resetShellCache(): void {
|
|
119
|
+
shellCache = null
|
|
120
|
+
}
|
|
121
|
+
|
|
98
122
|
export default function Dashboard() {
|
|
99
123
|
const explorerMounted = explorerIsMounted()
|
|
124
|
+
if (shellCache?.mounted === explorerMounted) return shellCache.html
|
|
125
|
+
|
|
100
126
|
const NAV = navSections(explorerMounted)
|
|
101
127
|
|
|
102
|
-
|
|
128
|
+
const html = (
|
|
103
129
|
<html lang="en">
|
|
104
130
|
<head>
|
|
105
131
|
<meta charSet="UTF-8" />
|
|
@@ -197,5 +223,12 @@ export default function Dashboard() {
|
|
|
197
223
|
<script src="/_dashboard/dashboard.js"></script>
|
|
198
224
|
</body>
|
|
199
225
|
</html>
|
|
200
|
-
)
|
|
226
|
+
) as unknown as string
|
|
227
|
+
// The same cast `raw()` makes in `core/jsx.ts`: `createElement` is declared
|
|
228
|
+
// to return `string` and actually returns a `SafeHtml`, a String subclass
|
|
229
|
+
// that behaves like one everywhere. Cached as it came rather than through
|
|
230
|
+
// `String()`, so nothing about the value changes by being remembered.
|
|
231
|
+
|
|
232
|
+
shellCache = { mounted: explorerMounted, html }
|
|
233
|
+
return html
|
|
201
234
|
}
|
|
@@ -1,327 +0,0 @@
|
|
|
1
|
-
export let refreshShimmerCache = () => {}
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Both effects below run on import. Without this guard, importing anything that
|
|
5
|
-
* transitively reaches this file outside a browser throws on the first `window`
|
|
6
|
-
* access — which made every module in `client/parts/` untestable, including the
|
|
7
|
-
* hand-built HTML in the since-retired `database.ts` that produced every XSS
|
|
8
|
-
* found in this repo. A DOM effect with no DOM is a no-op, not an error.
|
|
9
|
-
*/
|
|
10
|
-
const HAS_DOM = typeof window !== 'undefined' && typeof document !== 'undefined'
|
|
11
|
-
|
|
12
|
-
;(function initDashboardShimmer() {
|
|
13
|
-
if (!HAS_DOM) return
|
|
14
|
-
if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) return
|
|
15
|
-
|
|
16
|
-
let cachedCards: {
|
|
17
|
-
el: HTMLElement
|
|
18
|
-
width: number
|
|
19
|
-
height: number
|
|
20
|
-
pageLeft: number
|
|
21
|
-
pageTop: number
|
|
22
|
-
}[] = []
|
|
23
|
-
const mouse = { x: -9999, y: -9999 }
|
|
24
|
-
let rafId = 0
|
|
25
|
-
|
|
26
|
-
refreshShimmerCache = function updateRects() {
|
|
27
|
-
const cards = Array.from(
|
|
28
|
-
document.querySelectorAll('.glass-effect'),
|
|
29
|
-
) as HTMLElement[]
|
|
30
|
-
|
|
31
|
-
cachedCards = cards.map(el => {
|
|
32
|
-
const rect = el.getBoundingClientRect()
|
|
33
|
-
return {
|
|
34
|
-
el,
|
|
35
|
-
width: rect.width,
|
|
36
|
-
height: rect.height,
|
|
37
|
-
pageLeft: rect.left + window.scrollX,
|
|
38
|
-
pageTop: rect.top + window.scrollY,
|
|
39
|
-
}
|
|
40
|
-
})
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* The observer below fires for every subtree mutation on the page, and
|
|
45
|
-
* `refreshShimmerCache` reads `getBoundingClientRect` once per glass card —
|
|
46
|
-
* a forced synchronous layout. The log console appends one node per
|
|
47
|
-
* websocket frame, each in its own task, so a busy server bought one full
|
|
48
|
-
* layout pass per log line. Coalescing into a frame makes that cost
|
|
49
|
-
* proportional to frames, not to mutations.
|
|
50
|
-
*/
|
|
51
|
-
let refreshRafId = 0
|
|
52
|
-
function scheduleRefresh() {
|
|
53
|
-
if (refreshRafId) return
|
|
54
|
-
refreshRafId = requestAnimationFrame(() => {
|
|
55
|
-
refreshRafId = 0
|
|
56
|
-
refreshShimmerCache()
|
|
57
|
-
})
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
window.addEventListener('resize', scheduleRefresh, { passive: true })
|
|
61
|
-
|
|
62
|
-
const observer = new MutationObserver(scheduleRefresh)
|
|
63
|
-
observer.observe(document.body, {
|
|
64
|
-
childList: true,
|
|
65
|
-
subtree: true,
|
|
66
|
-
attributes: true,
|
|
67
|
-
attributeFilter: ['class'],
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
refreshShimmerCache()
|
|
71
|
-
|
|
72
|
-
function updateStyles() {
|
|
73
|
-
const scrollX = window.scrollX
|
|
74
|
-
const scrollY = window.scrollY
|
|
75
|
-
|
|
76
|
-
for (let i = 0; i < cachedCards.length; i++) {
|
|
77
|
-
const c = cachedCards[i]
|
|
78
|
-
const left = c.pageLeft - scrollX
|
|
79
|
-
const top = c.pageTop - scrollY
|
|
80
|
-
|
|
81
|
-
const rx = mouse.x - left
|
|
82
|
-
const ry = mouse.y - top
|
|
83
|
-
|
|
84
|
-
const closestX = Math.max(left, Math.min(mouse.x, left + c.width))
|
|
85
|
-
const closestY = Math.max(top, Math.min(mouse.y, top + c.height))
|
|
86
|
-
const dx = mouse.x - closestX
|
|
87
|
-
const dy = mouse.y - closestY
|
|
88
|
-
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
89
|
-
|
|
90
|
-
if (dist < 200) {
|
|
91
|
-
c.el.style.setProperty('--mouse-x', `${rx}px`)
|
|
92
|
-
c.el.style.setProperty('--mouse-y', `${ry}px`)
|
|
93
|
-
} else {
|
|
94
|
-
c.el.style.removeProperty('--mouse-x')
|
|
95
|
-
c.el.style.removeProperty('--mouse-y')
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
rafId = 0
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
window.addEventListener(
|
|
102
|
-
'pointermove',
|
|
103
|
-
e => {
|
|
104
|
-
mouse.x = e.clientX
|
|
105
|
-
mouse.y = e.clientY
|
|
106
|
-
if (!rafId) rafId = requestAnimationFrame(updateStyles)
|
|
107
|
-
},
|
|
108
|
-
{ passive: true },
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
window.addEventListener(
|
|
112
|
-
'scroll',
|
|
113
|
-
() => {
|
|
114
|
-
if (mouse.x === -9999) return
|
|
115
|
-
if (!rafId) rafId = requestAnimationFrame(updateStyles)
|
|
116
|
-
},
|
|
117
|
-
{ passive: true },
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
window.addEventListener('mouseleave', () => {
|
|
121
|
-
mouse.x = -9999
|
|
122
|
-
mouse.y = -9999
|
|
123
|
-
if (!rafId) rafId = requestAnimationFrame(updateStyles)
|
|
124
|
-
})
|
|
125
|
-
})()
|
|
126
|
-
|
|
127
|
-
;(function initDotPattern() {
|
|
128
|
-
if (!HAS_DOM) return
|
|
129
|
-
|
|
130
|
-
const canvas = document.createElement('canvas')
|
|
131
|
-
canvas.id = 'dot-pattern-canvas'
|
|
132
|
-
canvas.style.cssText = [
|
|
133
|
-
'position: fixed',
|
|
134
|
-
'inset: 0',
|
|
135
|
-
'width: 100%',
|
|
136
|
-
'height: 100%',
|
|
137
|
-
'z-index: -1',
|
|
138
|
-
'pointer-events: none',
|
|
139
|
-
].join(';')
|
|
140
|
-
document.documentElement.prepend(canvas)
|
|
141
|
-
|
|
142
|
-
const ctx = canvas.getContext('2d')!
|
|
143
|
-
const DOT_SPACING = 28
|
|
144
|
-
const DOT_RADIUS = 1.1
|
|
145
|
-
const GLOW_RADIUS = 130
|
|
146
|
-
|
|
147
|
-
interface Dot {
|
|
148
|
-
ox: number
|
|
149
|
-
oy: number
|
|
150
|
-
x: number
|
|
151
|
-
y: number
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
let dots: Dot[] = []
|
|
155
|
-
const mouse = { x: -9999, y: -9999 }
|
|
156
|
-
let isAnimating = false
|
|
157
|
-
let isPressed = false
|
|
158
|
-
/**
|
|
159
|
-
* The loop below keeps requesting frames while the pointer is anywhere on
|
|
160
|
-
* the page, which on a hidden tab is pure waste — and this console is the
|
|
161
|
-
* kind of page that lives in a background tab for hours. Ported from the
|
|
162
|
-
* example app's copy of this effect, which had the throttle where this one
|
|
163
|
-
* did not; the two files are separate copies on purpose (a plugin's client
|
|
164
|
-
* bundle and an app's script cannot share a module), so a fix to one is
|
|
165
|
-
* worth checking against the other.
|
|
166
|
-
*/
|
|
167
|
-
let isTabVisible = !document.hidden
|
|
168
|
-
|
|
169
|
-
function initDots() {
|
|
170
|
-
dots = []
|
|
171
|
-
const cols = Math.ceil(canvas.width / DOT_SPACING) + 1
|
|
172
|
-
const rows = Math.ceil(canvas.height / DOT_SPACING) + 1
|
|
173
|
-
|
|
174
|
-
for (let r = 0; r < rows; r++) {
|
|
175
|
-
for (let c = 0; c < cols; c++) {
|
|
176
|
-
const ox = c * DOT_SPACING
|
|
177
|
-
const oy = r * DOT_SPACING
|
|
178
|
-
dots.push({ ox, oy, x: ox, y: oy })
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function resize() {
|
|
184
|
-
canvas.width = window.innerWidth
|
|
185
|
-
canvas.height = window.innerHeight
|
|
186
|
-
initDots()
|
|
187
|
-
if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
|
|
188
|
-
draw()
|
|
189
|
-
} else {
|
|
190
|
-
if (!isAnimating) {
|
|
191
|
-
isAnimating = true
|
|
192
|
-
requestAnimationFrame(draw)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
interface DotTarget {
|
|
198
|
-
x: number
|
|
199
|
-
y: number
|
|
200
|
-
alpha: number
|
|
201
|
-
radius: number
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function computeDotTarget(dot: Dot): DotTarget {
|
|
205
|
-
const dx = mouse.x - dot.ox
|
|
206
|
-
const dy = mouse.y - dot.oy
|
|
207
|
-
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
208
|
-
|
|
209
|
-
let targetX = dot.ox
|
|
210
|
-
let targetY = dot.oy
|
|
211
|
-
let alpha = 0.07
|
|
212
|
-
let radius = DOT_RADIUS
|
|
213
|
-
|
|
214
|
-
if (dist < GLOW_RADIUS) {
|
|
215
|
-
const t = 1 - dist / GLOW_RADIUS
|
|
216
|
-
alpha = 0.07 + t * 0.35
|
|
217
|
-
radius = DOT_RADIUS + t * 0.9
|
|
218
|
-
|
|
219
|
-
if (dist > 0.01) {
|
|
220
|
-
const force = t * (isPressed ? 32 : 18)
|
|
221
|
-
if (isPressed) {
|
|
222
|
-
targetX = dot.ox + (dx / dist) * force
|
|
223
|
-
targetY = dot.oy + (dy / dist) * force
|
|
224
|
-
} else {
|
|
225
|
-
targetX = dot.ox - (dx / dist) * force
|
|
226
|
-
targetY = dot.oy - (dy / dist) * force
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return { x: targetX, y: targetY, alpha, radius }
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function moveDot(dot: Dot, target: DotTarget): boolean {
|
|
235
|
-
const dx = target.x - dot.x
|
|
236
|
-
const dy = target.y - dot.y
|
|
237
|
-
if (Math.abs(dx) > 0.01 || Math.abs(dy) > 0.01) {
|
|
238
|
-
dot.x += dx * 0.15
|
|
239
|
-
dot.y += dy * 0.15
|
|
240
|
-
return true
|
|
241
|
-
}
|
|
242
|
-
dot.x = target.x
|
|
243
|
-
dot.y = target.y
|
|
244
|
-
return false
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function draw() {
|
|
248
|
-
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
|
249
|
-
|
|
250
|
-
let needsMoreFrames = false
|
|
251
|
-
|
|
252
|
-
for (let i = 0; i < dots.length; i++) {
|
|
253
|
-
const dot = dots[i]
|
|
254
|
-
const target = computeDotTarget(dot)
|
|
255
|
-
if (moveDot(dot, target)) needsMoreFrames = true
|
|
256
|
-
|
|
257
|
-
ctx.beginPath()
|
|
258
|
-
ctx.arc(dot.x, dot.y, target.radius, 0, Math.PI * 2)
|
|
259
|
-
ctx.fillStyle = `rgba(255, 255, 255, ${target.alpha})`
|
|
260
|
-
ctx.fill()
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (
|
|
264
|
-
window.matchMedia('(hover: hover) and (pointer: fine)').matches &&
|
|
265
|
-
isTabVisible
|
|
266
|
-
) {
|
|
267
|
-
if (mouse.x !== -9999 || needsMoreFrames) {
|
|
268
|
-
requestAnimationFrame(draw)
|
|
269
|
-
} else {
|
|
270
|
-
isAnimating = false
|
|
271
|
-
}
|
|
272
|
-
} else {
|
|
273
|
-
// Hidden tab: stop the loop and let visibilitychange restart it, or the
|
|
274
|
-
// flag would stay true and the resume below would never fire.
|
|
275
|
-
isAnimating = false
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
|
|
280
|
-
window.addEventListener('pointermove', e => {
|
|
281
|
-
mouse.x = e.clientX
|
|
282
|
-
mouse.y = e.clientY
|
|
283
|
-
if (!isAnimating) {
|
|
284
|
-
isAnimating = true
|
|
285
|
-
requestAnimationFrame(draw)
|
|
286
|
-
}
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
window.addEventListener('mouseleave', () => {
|
|
290
|
-
mouse.x = -9999
|
|
291
|
-
mouse.y = -9999
|
|
292
|
-
isPressed = false
|
|
293
|
-
if (!isAnimating) {
|
|
294
|
-
isAnimating = true
|
|
295
|
-
requestAnimationFrame(draw)
|
|
296
|
-
}
|
|
297
|
-
})
|
|
298
|
-
|
|
299
|
-
window.addEventListener('pointerdown', () => {
|
|
300
|
-
isPressed = true
|
|
301
|
-
if (!isAnimating) {
|
|
302
|
-
isAnimating = true
|
|
303
|
-
requestAnimationFrame(draw)
|
|
304
|
-
}
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
window.addEventListener('pointerup', () => {
|
|
308
|
-
isPressed = false
|
|
309
|
-
if (!isAnimating) {
|
|
310
|
-
isAnimating = true
|
|
311
|
-
requestAnimationFrame(draw)
|
|
312
|
-
}
|
|
313
|
-
})
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
document.addEventListener('visibilitychange', () => {
|
|
317
|
-
isTabVisible = !document.hidden
|
|
318
|
-
if (isTabVisible && !isAnimating) {
|
|
319
|
-
isAnimating = true
|
|
320
|
-
requestAnimationFrame(draw)
|
|
321
|
-
}
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
window.addEventListener('resize', resize)
|
|
325
|
-
resize()
|
|
326
|
-
draw()
|
|
327
|
-
})()
|