@esportsplus/ui 0.56.2 → 0.56.4
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/build/components/bento-grid/scss/index.scss +1 -1
- package/build/components/index.d.ts +3 -0
- package/build/components/index.js +3 -0
- package/build/components/navigation-menu/index.d.ts +28 -0
- package/build/components/navigation-menu/index.js +419 -0
- package/build/components/navigation-menu/scss/index.scss +2 -0
- package/build/components/plan-card/index.d.ts +36 -0
- package/build/components/plan-card/index.js +257 -0
- package/build/components/plan-card/scss/index.scss +2 -0
- package/build/components/surface-field/index.d.ts +2200 -0
- package/build/components/surface-field/index.js +474 -0
- package/build/components/surface-field/scss/index.scss +2 -0
- package/package.json +1 -1
- package/src/components/bento-grid/scss/index.scss +3 -2
- package/src/components/index.ts +3 -0
- package/src/components/navigation-menu/index.ts +608 -0
- package/src/components/navigation-menu/scss/index.scss +213 -0
- package/src/components/navigation-menu/scss/variables.scss +24 -0
- package/src/components/plan-card/index.ts +404 -0
- package/src/components/plan-card/scss/index.scss +513 -0
- package/src/components/plan-card/scss/variables.scss +33 -0
- package/src/components/surface-field/index.ts +816 -0
- package/src/components/surface-field/scss/index.scss +115 -0
- package/src/components/surface-field/scss/variables.scss +11 -0
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
import { component, html, type Attributes } from '@esportsplus/template';
|
|
2
|
+
import './scss/index.scss';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// Maps world space onto the field: screen = world * zoom + (x, y). Read every frame, so mutating the same object pans
|
|
6
|
+
// and zooms the grid without a re-render.
|
|
7
|
+
type Camera = { x: number, y: number, zoom: number };
|
|
8
|
+
|
|
9
|
+
type Options = {
|
|
10
|
+
// Colour the lit dots and lines drift toward, blended in by `tint`.
|
|
11
|
+
accent?: string;
|
|
12
|
+
// Resting opacity of an unlit dot.
|
|
13
|
+
base?: number;
|
|
14
|
+
// 0 – 1: how deeply idle dots pulse in and out.
|
|
15
|
+
breathe?: number;
|
|
16
|
+
// 0 – 1: peak opacity the light lifts lines to; dots rise proportionally higher.
|
|
17
|
+
brightness?: number;
|
|
18
|
+
// Anchors the grid to a pannable, zoomable world (for example a node editor's viewport) instead of centring it.
|
|
19
|
+
camera?: Camera | null;
|
|
20
|
+
// Draw grid lines between neighbouring dots where the light reaches.
|
|
21
|
+
connected?: boolean;
|
|
22
|
+
// Distance between dots in px (world units when a camera is set).
|
|
23
|
+
gap?: number;
|
|
24
|
+
// Radius of the pointer light's line reach in px.
|
|
25
|
+
lineRadius?: number;
|
|
26
|
+
// How far dots under the pointer are nudged away, in px.
|
|
27
|
+
pointerPush?: number;
|
|
28
|
+
// Radius of the pointer light in px.
|
|
29
|
+
radius?: number;
|
|
30
|
+
// How far a click's ripple displaces the dots it passes, in px.
|
|
31
|
+
ripplePush?: number;
|
|
32
|
+
// Render a static texture: no pointer light, ripples, breathing or wandering.
|
|
33
|
+
still?: boolean;
|
|
34
|
+
// Widens (positive) or narrows (negative) the empty band around each surface, in px.
|
|
35
|
+
surfacePadding?: number;
|
|
36
|
+
// 0 – 1: how far lit dots and lines blend toward `accent`.
|
|
37
|
+
tint?: number;
|
|
38
|
+
// Let the light drift on its own while the pointer is away.
|
|
39
|
+
wander?: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type Rect = { bottom: number, left: number, right: number, top: number };
|
|
43
|
+
|
|
44
|
+
type Ripple = { born: number, x: number, y: number };
|
|
45
|
+
|
|
46
|
+
type Geometry = { height: number, width: number, x: number, y: number };
|
|
47
|
+
|
|
48
|
+
type Surface = Attributes & {
|
|
49
|
+
// Keep the surface inside its parent. Turn off for surfaces in an unbounded world such as a pannable canvas.
|
|
50
|
+
bounded?: boolean;
|
|
51
|
+
height?: number;
|
|
52
|
+
// Names the surface in its move and resize controls' accessible labels.
|
|
53
|
+
label?: string;
|
|
54
|
+
minHeight?: number;
|
|
55
|
+
minWidth?: number;
|
|
56
|
+
// Seeds the geometry and receives every move and resize, so edges or a minimap can follow the surface.
|
|
57
|
+
state?: Geometry;
|
|
58
|
+
width?: number;
|
|
59
|
+
x?: number;
|
|
60
|
+
y?: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
const ALPHA_LEVELS = 32;
|
|
65
|
+
|
|
66
|
+
const DEFAULTS: Required<Options> = {
|
|
67
|
+
accent: '#6683e8',
|
|
68
|
+
base: 0.14,
|
|
69
|
+
breathe: 0,
|
|
70
|
+
brightness: 0.2,
|
|
71
|
+
camera: null,
|
|
72
|
+
connected: true,
|
|
73
|
+
gap: 22,
|
|
74
|
+
lineRadius: 200,
|
|
75
|
+
pointerPush: 2,
|
|
76
|
+
radius: 250,
|
|
77
|
+
ripplePush: 6,
|
|
78
|
+
still: false,
|
|
79
|
+
surfacePadding: 0,
|
|
80
|
+
tint: 0,
|
|
81
|
+
wander: false
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Pointer absence, in ms, before a wandering light takes over.
|
|
85
|
+
const IDLE = 1500;
|
|
86
|
+
|
|
87
|
+
const KEYS = Object.keys(DEFAULTS) as (keyof Options)[];
|
|
88
|
+
|
|
89
|
+
// Share of a surface's halo added to the light around it.
|
|
90
|
+
const HALO = 0.75;
|
|
91
|
+
|
|
92
|
+
const RIPPLE_LIFE = 1.4;
|
|
93
|
+
|
|
94
|
+
const RIPPLE_SPEED = 520;
|
|
95
|
+
|
|
96
|
+
const RIPPLE_WIDTH = 36;
|
|
97
|
+
|
|
98
|
+
// Elements the field clears a space around and lights up.
|
|
99
|
+
const SELECTOR = '.surface-field-surface, [data-surface-field]';
|
|
100
|
+
|
|
101
|
+
const TAU = Math.PI * 2;
|
|
102
|
+
|
|
103
|
+
const TINT_LEVELS = 5;
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
let colors = new Map<string, [number, number, number]>(),
|
|
107
|
+
probe: CanvasRenderingContext2D | null = null,
|
|
108
|
+
z = 1;
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
function clamp(value: number, min: number, max: number) {
|
|
112
|
+
return Math.min(max, Math.max(min, value));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function falloff(distance: number, radius: number) {
|
|
116
|
+
if (radius <= 0 || distance >= radius) {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let t = distance / radius;
|
|
121
|
+
|
|
122
|
+
return (1 - t * t) * (1 - t * t);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Resolves any CSS colour (oklch, named, hex…) to rgb by painting it once.
|
|
126
|
+
function rgb(color: string) {
|
|
127
|
+
let cached = colors.get(color);
|
|
128
|
+
|
|
129
|
+
if (cached) {
|
|
130
|
+
return cached;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
probe ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true })!;
|
|
134
|
+
probe.clearRect(0, 0, 1, 1);
|
|
135
|
+
probe.fillStyle = '#000';
|
|
136
|
+
probe.fillStyle = color;
|
|
137
|
+
probe.fillRect(0, 0, 1, 1);
|
|
138
|
+
|
|
139
|
+
let data = probe.getImageData(0, 0, 1, 1).data,
|
|
140
|
+
value: [number, number, number] = [data[0], data[1], data[2]];
|
|
141
|
+
|
|
142
|
+
if (colors.size > 64) {
|
|
143
|
+
colors.clear();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
colors.set(color, value);
|
|
147
|
+
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
const surface = component<Surface>(
|
|
153
|
+
function(this, { bounded = true, height = 160, label = 'surface', minHeight = 72, minWidth = 120, state, width = 240, x = 0, y = 0, ...attributes }, content) {
|
|
154
|
+
let element: HTMLElement | undefined,
|
|
155
|
+
start: { h: number, pointerX: number, pointerY: number, scale: number, w: number, x: number, y: number } | null = null;
|
|
156
|
+
|
|
157
|
+
if (state) {
|
|
158
|
+
({ height, width, x, y } = state);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Written straight to the element: the field reads this geometry every frame, so a reactive binding would
|
|
162
|
+
// leave the grid a frame behind the drag.
|
|
163
|
+
function apply() {
|
|
164
|
+
if (!element) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let parent = element.parentElement;
|
|
169
|
+
|
|
170
|
+
width = Math.max(minWidth, width);
|
|
171
|
+
height = Math.max(minHeight, height);
|
|
172
|
+
|
|
173
|
+
if (bounded && parent) {
|
|
174
|
+
let pw = parent.clientWidth,
|
|
175
|
+
ph = parent.clientHeight;
|
|
176
|
+
|
|
177
|
+
width = clamp(width, minWidth, Math.max(minWidth, pw));
|
|
178
|
+
height = clamp(height, minHeight, Math.max(minHeight, ph));
|
|
179
|
+
x = clamp(x, 0, Math.max(0, pw - width));
|
|
180
|
+
y = clamp(y, 0, Math.max(0, ph - height));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
element.style.height = `${height}px`;
|
|
184
|
+
element.style.translate = `${x}px ${y}px`;
|
|
185
|
+
element.style.width = `${width}px`;
|
|
186
|
+
|
|
187
|
+
if (state && (state.height !== height || state.width !== width || state.x !== x || state.y !== y)) {
|
|
188
|
+
state.height = height;
|
|
189
|
+
state.width = width;
|
|
190
|
+
state.x = x;
|
|
191
|
+
state.y = y;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function begin(event: PointerEvent) {
|
|
196
|
+
if (event.button !== 0 || !element) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
201
|
+
element.classList.add('--active');
|
|
202
|
+
element.style.zIndex = String(++z);
|
|
203
|
+
// Inside a zoomed world the pointer travels in screen px; dividing by the rendered scale keeps the surface
|
|
204
|
+
// under the cursor.
|
|
205
|
+
start = {
|
|
206
|
+
h: height,
|
|
207
|
+
pointerX: event.clientX,
|
|
208
|
+
pointerY: event.clientY,
|
|
209
|
+
scale: element.getBoundingClientRect().width / element.offsetWidth || 1,
|
|
210
|
+
w: width,
|
|
211
|
+
x,
|
|
212
|
+
y
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function end() {
|
|
217
|
+
element?.classList.remove('--active');
|
|
218
|
+
start = null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function nudge(event: KeyboardEvent, resize: boolean) {
|
|
222
|
+
let step = event.shiftKey ? 32 : 8,
|
|
223
|
+
dx = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0,
|
|
224
|
+
dy = event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0;
|
|
225
|
+
|
|
226
|
+
if (!dx && !dy) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
|
|
232
|
+
if (resize) {
|
|
233
|
+
width += dx;
|
|
234
|
+
height += dy;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
x += dx;
|
|
238
|
+
y += dy;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
apply();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return html`
|
|
245
|
+
<div
|
|
246
|
+
class='surface-field-surface'
|
|
247
|
+
${this?.attributes}
|
|
248
|
+
${attributes}
|
|
249
|
+
${{
|
|
250
|
+
onconnect: (el: HTMLElement) => {
|
|
251
|
+
element = el;
|
|
252
|
+
apply();
|
|
253
|
+
}
|
|
254
|
+
}}
|
|
255
|
+
>
|
|
256
|
+
<button
|
|
257
|
+
aria-label='${`Move ${label} surface with arrow keys`}'
|
|
258
|
+
class='surface-field-surface-handle'
|
|
259
|
+
type='button'
|
|
260
|
+
${{
|
|
261
|
+
onkeydown: (event: KeyboardEvent) => nudge(event, false),
|
|
262
|
+
onlostpointercapture: end,
|
|
263
|
+
onpointerdown: begin,
|
|
264
|
+
onpointermove: (event: PointerEvent) => {
|
|
265
|
+
if (!start) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
x = start.x + (event.clientX - start.pointerX) / start.scale;
|
|
270
|
+
y = start.y + (event.clientY - start.pointerY) / start.scale;
|
|
271
|
+
apply();
|
|
272
|
+
}
|
|
273
|
+
}}
|
|
274
|
+
></button>
|
|
275
|
+
<div class='surface-field-surface-content'>${content}</div>
|
|
276
|
+
<button
|
|
277
|
+
aria-label='${`Resize ${label} surface with arrow keys; right and down make it larger`}'
|
|
278
|
+
class='surface-field-surface-resize'
|
|
279
|
+
type='button'
|
|
280
|
+
${{
|
|
281
|
+
onkeydown: (event: KeyboardEvent) => nudge(event, true),
|
|
282
|
+
onlostpointercapture: end,
|
|
283
|
+
onpointerdown: begin,
|
|
284
|
+
onpointermove: (event: PointerEvent) => {
|
|
285
|
+
if (!start) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
width = start.w + (event.clientX - start.pointerX) / start.scale;
|
|
290
|
+
height = start.h + (event.clientY - start.pointerY) / start.scale;
|
|
291
|
+
apply();
|
|
292
|
+
}
|
|
293
|
+
}}
|
|
294
|
+
></button>
|
|
295
|
+
</div>
|
|
296
|
+
`;
|
|
297
|
+
}
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
export default Object.assign(
|
|
302
|
+
component<Attributes & Options & { state?: Options }>(
|
|
303
|
+
function(this, attributes, content) {
|
|
304
|
+
let given: Options = {},
|
|
305
|
+
rest: Attributes = {},
|
|
306
|
+
state = attributes.state;
|
|
307
|
+
|
|
308
|
+
for (let key in attributes) {
|
|
309
|
+
if (key === 'state') {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if ((KEYS as string[]).includes(key)) {
|
|
314
|
+
(given as Record<string, unknown>)[key] = attributes[key];
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
rest[key] = attributes[key];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let canvas: HTMLCanvasElement | undefined,
|
|
322
|
+
context: CanvasRenderingContext2D | null = null,
|
|
323
|
+
frame = 0,
|
|
324
|
+
host: HTMLElement | undefined,
|
|
325
|
+
key = '',
|
|
326
|
+
last = 0,
|
|
327
|
+
light = { intensity: 0, x: 0, y: 0 },
|
|
328
|
+
reduced = matchMedia('(prefers-reduced-motion: reduce)'),
|
|
329
|
+
observer: IntersectionObserver | undefined,
|
|
330
|
+
pointer = { at: -Infinity, inside: false, x: 0, y: 0 },
|
|
331
|
+
ripples: Ripple[] = [],
|
|
332
|
+
size = { dpr: 0, height: 0, width: 0 },
|
|
333
|
+
visible = false;
|
|
334
|
+
|
|
335
|
+
// Grid buffers, regrown only when the dot count rises.
|
|
336
|
+
let bucket = new Uint16Array(0),
|
|
337
|
+
dotAlpha = new Float32Array(0),
|
|
338
|
+
dotLight = new Float32Array(0),
|
|
339
|
+
lineLight = new Float32Array(0),
|
|
340
|
+
order = new Uint32Array(0),
|
|
341
|
+
px = new Float32Array(0),
|
|
342
|
+
py = new Float32Array(0),
|
|
343
|
+
segment = new Uint32Array(0),
|
|
344
|
+
segmentBucket = new Uint16Array(0),
|
|
345
|
+
segmentOrder = new Uint32Array(0),
|
|
346
|
+
seed = new Float32Array(0);
|
|
347
|
+
|
|
348
|
+
function options() {
|
|
349
|
+
let snapshot = {} as Required<Options>;
|
|
350
|
+
|
|
351
|
+
for (let i = 0, n = KEYS.length; i < n; i++) {
|
|
352
|
+
let k = KEYS[i];
|
|
353
|
+
|
|
354
|
+
(snapshot as Record<string, unknown>)[k] = state?.[k] ?? given[k] ?? DEFAULTS[k];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Copied so the frame works from (and compares against) the values of this moment, not a live object.
|
|
358
|
+
let camera = snapshot.camera;
|
|
359
|
+
|
|
360
|
+
if (camera) {
|
|
361
|
+
snapshot.camera = { x: camera.x, y: camera.y, zoom: camera.zoom };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return snapshot;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function grow(n: number) {
|
|
368
|
+
if (px.length >= n) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
bucket = new Uint16Array(n);
|
|
373
|
+
dotAlpha = new Float32Array(n);
|
|
374
|
+
dotLight = new Float32Array(n);
|
|
375
|
+
lineLight = new Float32Array(n);
|
|
376
|
+
order = new Uint32Array(n);
|
|
377
|
+
px = new Float32Array(n);
|
|
378
|
+
py = new Float32Array(n);
|
|
379
|
+
segment = new Uint32Array(n * 4);
|
|
380
|
+
segmentBucket = new Uint16Array(n * 2);
|
|
381
|
+
segmentOrder = new Uint32Array(n * 2);
|
|
382
|
+
seed = new Float32Array(n);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function start() {
|
|
386
|
+
if (!frame && visible && host?.isConnected) {
|
|
387
|
+
last = performance.now();
|
|
388
|
+
frame = requestAnimationFrame(tick);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function stop() {
|
|
393
|
+
cancelAnimationFrame(frame);
|
|
394
|
+
frame = 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function tick(now: number) {
|
|
398
|
+
frame = requestAnimationFrame(tick);
|
|
399
|
+
|
|
400
|
+
if (!host || !canvas || !context) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let dt = Math.min(64, now - last) / 1000,
|
|
405
|
+
o = options(),
|
|
406
|
+
width = host.clientWidth,
|
|
407
|
+
height = host.clientHeight,
|
|
408
|
+
dpr = Math.min(2, devicePixelRatio || 1);
|
|
409
|
+
|
|
410
|
+
last = now;
|
|
411
|
+
|
|
412
|
+
if (width !== size.width || height !== size.height || dpr !== size.dpr) {
|
|
413
|
+
canvas.height = Math.max(1, Math.round(height * dpr));
|
|
414
|
+
canvas.width = Math.max(1, Math.round(width * dpr));
|
|
415
|
+
size = { dpr, height, width };
|
|
416
|
+
key = '';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Surface geometry is read fresh each frame so a drag, a resize or a layout shift is followed at once.
|
|
420
|
+
let bounds = host.getBoundingClientRect(),
|
|
421
|
+
elements = host.querySelectorAll(SELECTOR),
|
|
422
|
+
rects: Rect[] = [];
|
|
423
|
+
|
|
424
|
+
for (let i = 0, n = elements.length; i < n; i++) {
|
|
425
|
+
let r = elements[i].getBoundingClientRect();
|
|
426
|
+
|
|
427
|
+
if (r.width && r.height) {
|
|
428
|
+
rects.push({
|
|
429
|
+
bottom: r.bottom - bounds.top,
|
|
430
|
+
left: r.left - bounds.left,
|
|
431
|
+
right: r.right - bounds.left,
|
|
432
|
+
top: r.top - bounds.top
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let animate = !o.still && !reduced.matches,
|
|
438
|
+
intensity = 0,
|
|
439
|
+
target = { x: light.x, y: light.y },
|
|
440
|
+
wandering = false;
|
|
441
|
+
|
|
442
|
+
if (animate && pointer.inside) {
|
|
443
|
+
intensity = 1;
|
|
444
|
+
target = { x: pointer.x, y: pointer.y };
|
|
445
|
+
}
|
|
446
|
+
else if (animate && o.wander && now - pointer.at > IDLE) {
|
|
447
|
+
let t = now / 1000;
|
|
448
|
+
|
|
449
|
+
intensity = 0.85;
|
|
450
|
+
target = {
|
|
451
|
+
x: width / 2 + Math.cos(t * 0.23) * width * 0.34 + Math.sin(t * 0.57) * width * 0.08,
|
|
452
|
+
y: height / 2 + Math.sin(t * 0.31) * height * 0.3 + Math.cos(t * 0.49) * height * 0.08
|
|
453
|
+
};
|
|
454
|
+
wandering = true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// A light fading in from nothing starts where it is aimed rather than sweeping across from its last spot.
|
|
458
|
+
if (light.intensity < 0.01 && intensity > 0) {
|
|
459
|
+
light.x = target.x;
|
|
460
|
+
light.y = target.y;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
let follow = 1 - Math.exp(-dt * (wandering ? 2.5 : 16)),
|
|
464
|
+
fade = 1 - Math.exp(-dt * 6);
|
|
465
|
+
|
|
466
|
+
light.intensity += (intensity - light.intensity) * fade;
|
|
467
|
+
light.x += (target.x - light.x) * follow;
|
|
468
|
+
light.y += (target.y - light.y) * follow;
|
|
469
|
+
|
|
470
|
+
if (!animate) {
|
|
471
|
+
light.intensity = 0;
|
|
472
|
+
ripples.length = 0;
|
|
473
|
+
}
|
|
474
|
+
else if (ripples.length) {
|
|
475
|
+
ripples = ripples.filter((ripple) => (now - ripple.born) / 1000 < RIPPLE_LIFE);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let color = getComputedStyle(canvas).color,
|
|
479
|
+
settled = Math.abs(intensity - light.intensity) < 0.002
|
|
480
|
+
&& (light.intensity < 0.002 || (Math.abs(target.x - light.x) < 0.1 && Math.abs(target.y - light.y) < 0.1)),
|
|
481
|
+
signature = JSON.stringify([o, color, rects, pointer.inside && animate ? [pointer.x, pointer.y] : 0]);
|
|
482
|
+
|
|
483
|
+
// Nothing moving and nothing changed: the previous frame still stands.
|
|
484
|
+
if (settled && !wandering && !ripples.length && !(animate && o.breathe > 0) && signature === key) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
key = signature;
|
|
489
|
+
draw(o, rects, color, animate ? now : 0, animate);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function draw(o: Required<Options>, rects: Rect[], color: string, now: number, animate: boolean) {
|
|
493
|
+
let ctx = context!,
|
|
494
|
+
{ dpr, height, width } = size,
|
|
495
|
+
camera = o.camera,
|
|
496
|
+
gap = Math.max(6, o.gap) * (camera ? Math.max(0.01, camera.zoom) : 1),
|
|
497
|
+
zoom = camera ? Math.max(0.01, camera.zoom) : 1;
|
|
498
|
+
|
|
499
|
+
// Zoomed far out, every other row and column drops so the dots never crowd into a grey wash.
|
|
500
|
+
while (gap < 8) {
|
|
501
|
+
gap *= 2;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let cols = Math.floor(width / gap) + 2,
|
|
505
|
+
rows = Math.floor(height / gap) + 2,
|
|
506
|
+
n = cols * rows,
|
|
507
|
+
// World index of the first column and row, so a dot keeps its identity (and its breathing phase)
|
|
508
|
+
// while the camera pans.
|
|
509
|
+
i0 = camera ? Math.floor(-camera.x / gap) : 0,
|
|
510
|
+
j0 = camera ? Math.floor(-camera.y / gap) : 0,
|
|
511
|
+
ox = camera ? camera.x + i0 * gap : (width - (cols - 1) * gap) / 2,
|
|
512
|
+
oy = camera ? camera.y + j0 * gap : (height - (rows - 1) * gap) / 2,
|
|
513
|
+
padding = o.surfacePadding * zoom,
|
|
514
|
+
clear = Math.max(2, gap * 0.8 + padding),
|
|
515
|
+
band = Math.max(clear + gap, gap * 3 + padding),
|
|
516
|
+
halo = band + gap * 1.5,
|
|
517
|
+
push = Math.min(o.radius, gap * 5),
|
|
518
|
+
peak = Math.min(1, o.brightness * 2.5),
|
|
519
|
+
time = now / 1000;
|
|
520
|
+
|
|
521
|
+
grow(n);
|
|
522
|
+
|
|
523
|
+
for (let j = 0; j < rows; j++) {
|
|
524
|
+
for (let i = 0; i < cols; i++) {
|
|
525
|
+
let k = j * cols + i,
|
|
526
|
+
x = ox + i * gap,
|
|
527
|
+
y = oy + j * gap,
|
|
528
|
+
glow = 0,
|
|
529
|
+
shown = 1;
|
|
530
|
+
|
|
531
|
+
seed[k] = ((Math.sin((i + i0) * 12.9898 + (j + j0) * 78.233) * 43758.5453) % 1 + 1) % 1;
|
|
532
|
+
|
|
533
|
+
// Each surface clears the dots it covers and squeezes the band around it outward, so the grid
|
|
534
|
+
// bends around the surface's edge and rounds off at its corners.
|
|
535
|
+
for (let r = 0, m = rects.length; r < m; r++) {
|
|
536
|
+
let rect = rects[r],
|
|
537
|
+
cx = clamp(x, rect.left, rect.right),
|
|
538
|
+
cy = clamp(y, rect.top, rect.bottom),
|
|
539
|
+
dx = x - cx,
|
|
540
|
+
dy = y - cy,
|
|
541
|
+
d = Math.sqrt(dx * dx + dy * dy);
|
|
542
|
+
|
|
543
|
+
if (d === 0) {
|
|
544
|
+
shown = 0;
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (d < halo) {
|
|
549
|
+
glow = Math.max(glow, 1 - d / halo);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (d < band) {
|
|
553
|
+
let scale = (clear + d * (band - clear) / band) / d;
|
|
554
|
+
|
|
555
|
+
x = cx + dx * scale;
|
|
556
|
+
y = cy + dy * scale;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
let dot = glow * glow * HALO,
|
|
561
|
+
line = dot;
|
|
562
|
+
|
|
563
|
+
if (shown && light.intensity > 0.001) {
|
|
564
|
+
let dx = x - light.x,
|
|
565
|
+
dy = y - light.y,
|
|
566
|
+
d = Math.sqrt(dx * dx + dy * dy);
|
|
567
|
+
|
|
568
|
+
dot += falloff(d, o.radius) * light.intensity;
|
|
569
|
+
line += falloff(d, o.lineRadius) * light.intensity;
|
|
570
|
+
|
|
571
|
+
if (pointer.inside && o.pointerPush && d > 0.001 && d < push) {
|
|
572
|
+
let s = o.pointerPush * (1 - d / push) * (1 - d / push) * light.intensity / d;
|
|
573
|
+
|
|
574
|
+
x += dx * s;
|
|
575
|
+
y += dy * s;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
for (let r = 0, m = shown ? ripples.length : 0; r < m; r++) {
|
|
580
|
+
let ripple = ripples[r],
|
|
581
|
+
age = (now - ripple.born) / 1000,
|
|
582
|
+
dx = x - ripple.x,
|
|
583
|
+
dy = y - ripple.y,
|
|
584
|
+
d = Math.sqrt(dx * dx + dy * dy),
|
|
585
|
+
life = 1 - age / RIPPLE_LIFE,
|
|
586
|
+
wave = (d - age * RIPPLE_SPEED) / RIPPLE_WIDTH,
|
|
587
|
+
strength = Math.exp(-wave * wave) * life * life;
|
|
588
|
+
|
|
589
|
+
if (d > 0.001) {
|
|
590
|
+
x += dx / d * o.ripplePush * strength;
|
|
591
|
+
y += dy / d * o.ripplePush * strength;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
dot += strength * 0.8;
|
|
595
|
+
line += strength * 0.6;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
let breath = animate && o.breathe > 0
|
|
599
|
+
? 1 - o.breathe * 0.5 * (1 + Math.sin(time * 1.3 + seed[k] * TAU))
|
|
600
|
+
: 1;
|
|
601
|
+
|
|
602
|
+
dot = Math.min(1, dot);
|
|
603
|
+
px[k] = x;
|
|
604
|
+
py[k] = y;
|
|
605
|
+
dotLight[k] = dot;
|
|
606
|
+
dotAlpha[k] = shown * breath * (o.base + (peak - o.base) * Math.max(0, dot));
|
|
607
|
+
lineLight[k] = shown * Math.min(1, line);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
let accent = rgb(o.accent),
|
|
612
|
+
base = rgb(color),
|
|
613
|
+
buckets = ALPHA_LEVELS * TINT_LEVELS,
|
|
614
|
+
counts = new Uint32Array(buckets + 1),
|
|
615
|
+
fills: string[] = [],
|
|
616
|
+
tint = clamp(o.tint, 0, 1);
|
|
617
|
+
|
|
618
|
+
for (let t = 0; t < TINT_LEVELS; t++) {
|
|
619
|
+
let mix = tint * t / (TINT_LEVELS - 1);
|
|
620
|
+
|
|
621
|
+
fills[t] = `rgb(${
|
|
622
|
+
Math.round(base[0] + (accent[0] - base[0]) * mix)
|
|
623
|
+
} ${
|
|
624
|
+
Math.round(base[1] + (accent[1] - base[1]) * mix)
|
|
625
|
+
} ${
|
|
626
|
+
Math.round(base[2] + (accent[2] - base[2]) * mix)
|
|
627
|
+
})`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
631
|
+
ctx.clearRect(0, 0, width, height);
|
|
632
|
+
|
|
633
|
+
// Lines and dots are bucketed by opacity and tint so each bucket draws as a single path.
|
|
634
|
+
if (o.connected) {
|
|
635
|
+
let segments = 0;
|
|
636
|
+
|
|
637
|
+
for (let j = 0; j < rows; j++) {
|
|
638
|
+
for (let i = 0; i < cols; i++) {
|
|
639
|
+
let k = j * cols + i;
|
|
640
|
+
|
|
641
|
+
for (let neighbour of [i + 1 < cols ? k + 1 : -1, j + 1 < rows ? k + cols : -1]) {
|
|
642
|
+
if (neighbour < 0) {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
let lit = Math.min(lineLight[k], lineLight[neighbour]),
|
|
647
|
+
alpha = Math.round(lit * o.brightness * (ALPHA_LEVELS - 1));
|
|
648
|
+
|
|
649
|
+
if (alpha < 1) {
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
segment[segments * 2] = k;
|
|
654
|
+
segment[segments * 2 + 1] = neighbour;
|
|
655
|
+
segmentBucket[segments] = Math.round(lit * (TINT_LEVELS - 1)) * ALPHA_LEVELS + Math.min(alpha, ALPHA_LEVELS - 1);
|
|
656
|
+
counts[segmentBucket[segments] + 1]++;
|
|
657
|
+
segments++;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
sort(counts, segmentBucket, segmentOrder, segments);
|
|
663
|
+
ctx.lineWidth = 1;
|
|
664
|
+
|
|
665
|
+
for (let b = 0; b < buckets; b++) {
|
|
666
|
+
let from = counts[b],
|
|
667
|
+
to = counts[b + 1];
|
|
668
|
+
|
|
669
|
+
if (from === to) {
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
ctx.beginPath();
|
|
674
|
+
|
|
675
|
+
for (let s = from; s < to; s++) {
|
|
676
|
+
let a = segment[segmentOrder[s] * 2],
|
|
677
|
+
c = segment[segmentOrder[s] * 2 + 1];
|
|
678
|
+
|
|
679
|
+
ctx.moveTo(px[a], py[a]);
|
|
680
|
+
ctx.lineTo(px[c], py[c]);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
ctx.globalAlpha = (b % ALPHA_LEVELS) / (ALPHA_LEVELS - 1);
|
|
684
|
+
ctx.strokeStyle = fills[Math.floor(b / ALPHA_LEVELS)];
|
|
685
|
+
ctx.stroke();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
counts.fill(0);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
let dots = 0;
|
|
692
|
+
|
|
693
|
+
for (let k = 0; k < n; k++) {
|
|
694
|
+
let alpha = Math.round(dotAlpha[k] * (ALPHA_LEVELS - 1));
|
|
695
|
+
|
|
696
|
+
if (alpha < 1) {
|
|
697
|
+
bucket[k] = buckets;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
bucket[k] = Math.round(dotLight[k] * (TINT_LEVELS - 1)) * ALPHA_LEVELS + Math.min(alpha, ALPHA_LEVELS - 1);
|
|
702
|
+
counts[bucket[k] + 1]++;
|
|
703
|
+
dots++;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
sort(counts, bucket, order, n);
|
|
707
|
+
|
|
708
|
+
for (let b = 0; b < buckets; b++) {
|
|
709
|
+
let from = counts[b],
|
|
710
|
+
to = counts[b + 1];
|
|
711
|
+
|
|
712
|
+
if (from === to) {
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
let r = 0.85 + 0.45 * Math.floor(b / ALPHA_LEVELS) / (TINT_LEVELS - 1);
|
|
717
|
+
|
|
718
|
+
ctx.beginPath();
|
|
719
|
+
|
|
720
|
+
for (let s = from; s < to; s++) {
|
|
721
|
+
let k = order[s];
|
|
722
|
+
|
|
723
|
+
ctx.moveTo(px[k] + r, py[k]);
|
|
724
|
+
ctx.arc(px[k], py[k], r, 0, TAU);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
ctx.globalAlpha = (b % ALPHA_LEVELS) / (ALPHA_LEVELS - 1);
|
|
728
|
+
ctx.fillStyle = fills[Math.floor(b / ALPHA_LEVELS)];
|
|
729
|
+
ctx.fill();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
ctx.globalAlpha = 1;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Counting sort: `counts` holds per-bucket tallies offset by one and becomes each bucket's start index.
|
|
736
|
+
function sort(counts: Uint32Array, keys: Uint16Array, into: Uint32Array, n: number) {
|
|
737
|
+
for (let b = 1, m = counts.length; b < m; b++) {
|
|
738
|
+
counts[b] += counts[b - 1];
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
let cursor = counts.slice();
|
|
742
|
+
|
|
743
|
+
for (let i = 0; i < n; i++) {
|
|
744
|
+
if (keys[i] < counts.length - 1) {
|
|
745
|
+
into[cursor[keys[i]]++] = i;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function track(event: PointerEvent) {
|
|
751
|
+
if (!host) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
let bounds = host.getBoundingClientRect();
|
|
756
|
+
|
|
757
|
+
pointer.at = performance.now();
|
|
758
|
+
pointer.inside = true;
|
|
759
|
+
pointer.x = event.clientX - bounds.left;
|
|
760
|
+
pointer.y = event.clientY - bounds.top;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return html`
|
|
764
|
+
<div
|
|
765
|
+
class='surface-field'
|
|
766
|
+
${this?.attributes}
|
|
767
|
+
${rest}
|
|
768
|
+
${{
|
|
769
|
+
onconnect: (element: HTMLElement) => {
|
|
770
|
+
host = element;
|
|
771
|
+
canvas = element.querySelector('.surface-field-canvas') as HTMLCanvasElement;
|
|
772
|
+
context = canvas.getContext('2d');
|
|
773
|
+
observer = new IntersectionObserver((entries) => {
|
|
774
|
+
visible = entries[entries.length - 1].isIntersecting;
|
|
775
|
+
|
|
776
|
+
if (visible) {
|
|
777
|
+
start();
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
stop();
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
observer.observe(element);
|
|
784
|
+
},
|
|
785
|
+
ondisconnect: () => {
|
|
786
|
+
observer?.disconnect();
|
|
787
|
+
stop();
|
|
788
|
+
},
|
|
789
|
+
onpointerdown: (event: PointerEvent) => {
|
|
790
|
+
track(event);
|
|
791
|
+
|
|
792
|
+
if (event.button !== 0 || (event.target as Element).closest?.(SELECTOR)) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
ripples.push({ born: performance.now(), x: pointer.x, y: pointer.y });
|
|
797
|
+
|
|
798
|
+
if (ripples.length > 6) {
|
|
799
|
+
ripples.shift();
|
|
800
|
+
}
|
|
801
|
+
},
|
|
802
|
+
onpointerleave: () => {
|
|
803
|
+
pointer.at = performance.now();
|
|
804
|
+
pointer.inside = false;
|
|
805
|
+
},
|
|
806
|
+
onpointermove: track
|
|
807
|
+
}}
|
|
808
|
+
>
|
|
809
|
+
<canvas aria-hidden='true' class='surface-field-canvas'></canvas>
|
|
810
|
+
<div class='surface-field-content'>${content}</div>
|
|
811
|
+
</div>
|
|
812
|
+
`;
|
|
813
|
+
}
|
|
814
|
+
),
|
|
815
|
+
{ surface }
|
|
816
|
+
);
|