@konce-pt/backdrop 0.8.5
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/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/color.d.ts +101 -0
- package/dist/color.js +228 -0
- package/dist/hash.d.ts +12 -0
- package/dist/hash.js +19 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -0
- package/dist/renderer.d.ts +176 -0
- package/dist/renderer.js +348 -0
- package/dist/signal-grid.d.ts +35 -0
- package/dist/signal-grid.js +88 -0
- package/dist/types.d.ts +96 -0
- package/dist/types.js +1 -0
- package/dist/wave.d.ts +58 -0
- package/dist/wave.js +107 -0
- package/package.json +47 -0
package/dist/renderer.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderery efektów tła.
|
|
3
|
+
*
|
|
4
|
+
* Rysowanie nie potrzebuje frameworka — potrzebuje `CanvasRenderingContext2D`. Dzięki temu
|
|
5
|
+
* komponent w Angularze i w Reakcie zostaje samym cyklem życia (rAF, obserwatory rozmiaru,
|
|
6
|
+
* widoczności i motywu) wokół tego samego renderera, zamiast dwa razy powtarzać pętlę rysowania.
|
|
7
|
+
*
|
|
8
|
+
* Kolory wchodzą tu już rozwiązane: canvas nie rozwija `var()`, więc token na konkretny kolor
|
|
9
|
+
* zamienia wywołujący, który jedyny wie, na jakim elemencie go odczytać.
|
|
10
|
+
*/
|
|
11
|
+
import { adaptToSurface, mixOklch, parseHexColor, prefersDarkContent, readCssColor, shiftHue, toHexColor, } from "./color.js";
|
|
12
|
+
import { buildSignalGrid, signalGridAlpha } from "./signal-grid.js";
|
|
13
|
+
import { resolveWave, waveBuffer, waveIntensity, waveRamp } from "./wave.js";
|
|
14
|
+
/** Poniżej tego krycia kwadrat i tak nie byłby widoczny — nie ma po co go rysować. */
|
|
15
|
+
const ALPHA_CUTOFF = 0.02;
|
|
16
|
+
/** Ile przystanków ma podręczna tablica barw rampy. */
|
|
17
|
+
const LUT_STOPS = 256;
|
|
18
|
+
/** Gęstość pikseli powyżej dwóch nic nie wnosi przy tych efektach, a kosztuje kwadratowo. */
|
|
19
|
+
export const KPT_BACKDROP_MAX_DPR = 2;
|
|
20
|
+
/** Górna granica kroku czasu (ms) — karta wróciwszy z tła nie ma przewijać animacji do przodu. */
|
|
21
|
+
export const KPT_BACKDROP_MAX_STEP = 100;
|
|
22
|
+
/** O tyle stopni rozchodzą się domyślne barwy wstęgi względem koloru wiodącego. */
|
|
23
|
+
export const KPT_BACKDROP_RAMP_SPREAD = 28;
|
|
24
|
+
/** Własność-sonda; żyje tylko na czas jednego odczytu. */
|
|
25
|
+
const PROBE_PROPERTY = '--_kpt-probe';
|
|
26
|
+
/**
|
|
27
|
+
* Wyrażenie CSS rozwiązane względem drzewa: `var()` podstawione wartościami obowiązującymi w tym
|
|
28
|
+
* miejscu dokumentu. Dzięki temu wejście koloru może sięgnąć po token i samo iść za motywem,
|
|
29
|
+
* zamiast zamarzać na wartości z chwili napisania szablonu.
|
|
30
|
+
*
|
|
31
|
+
* `scope` musi być **potomkiem** hosta. `observeBackdropTheme` ogląda `style` na hoście i każdym
|
|
32
|
+
* jego przodku, więc sonda zapisana na hoście obudziłaby nasłuch, ten sięgnąłby po kolor na nowo,
|
|
33
|
+
* znów zapisał sondę — i pętla nie miałaby końca. Potomek jest poza zasięgiem nasłuchu.
|
|
34
|
+
*
|
|
35
|
+
* Bramka na `var(` nie jest kosmetyczna: bez niej każdy literał kosztowałby zapis stylu
|
|
36
|
+
* i wymuszone przeliczenie kaskady, a nie ma w nim czego rozwiązywać.
|
|
37
|
+
*
|
|
38
|
+
* Odwołanie do nieistniejącej zmiennej daje pusty napis — to sygnał „nie dało się", który
|
|
39
|
+
* wywołujący czyta jako brak wartości.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveCssValue(scope, value) {
|
|
42
|
+
if (!value.includes('var('))
|
|
43
|
+
return value;
|
|
44
|
+
scope.style.setProperty(PROBE_PROPERTY, value);
|
|
45
|
+
const resolved = getComputedStyle(scope).getPropertyValue(PROBE_PROPERTY).trim();
|
|
46
|
+
scope.style.removeProperty(PROBE_PROPERTY);
|
|
47
|
+
return resolved;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Sam kolor wiodący: wejście komponentu, a bez niego token odczytany z wyliczonego stylu.
|
|
51
|
+
*
|
|
52
|
+
* Wydzielone z `resolveBackdropColors`, bo służy też za tani strażnik przy zmianie motywu —
|
|
53
|
+
* `getComputedStyle` bez sondy 1×1 i bez arytmetyki barw.
|
|
54
|
+
*
|
|
55
|
+
* Wejście przechodzi przez `resolveCssValue`, więc `var(--kpt-color-primary)` działa i zmienia się
|
|
56
|
+
* razem z motywem, a literał zostaje literałem. Zapis odwołujący się do zmiennej, której nie ma,
|
|
57
|
+
* schodzi do tokenu — tego chciałby autor literówki w nazwie.
|
|
58
|
+
*/
|
|
59
|
+
export function readBackdropLead(scope, color) {
|
|
60
|
+
const explicit = resolveCssValue(scope, color.trim());
|
|
61
|
+
if (explicit)
|
|
62
|
+
return explicit;
|
|
63
|
+
const style = getComputedStyle(scope);
|
|
64
|
+
return style.getPropertyValue('--_dot').trim() || style.color;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Czy adaptacja ma tę wartość ruszać.
|
|
68
|
+
*
|
|
69
|
+
* Pusta bierze token, a zapis z `var()` sam sięga po token — jedno i drugie już idzie za motywem
|
|
70
|
+
* i przeliczenie odwróciłoby je drugi raz, czyli z powrotem. Adaptacja dotyczy wyłącznie tego,
|
|
71
|
+
* co autor wpisał na sztywno, bo tylko to nie umie się samo ruszyć.
|
|
72
|
+
*/
|
|
73
|
+
const adaptable = (value) => value.trim() !== '' && !value.includes('var(');
|
|
74
|
+
/** Powierzchnia bieżącego motywu — punkt odniesienia adaptacji. */
|
|
75
|
+
export function readBackdropSurface(scope) {
|
|
76
|
+
return readCssColor(resolveCssValue(scope, 'var(--kpt-color-surface)'));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Barwa przeniesiona na powierzchnię bieżącego motywu. Wartość, która nie podlega adaptacji albo
|
|
80
|
+
* której nie da się odczytać, wraca bez zmian — adaptacja nigdy nie gubi tego, co podano.
|
|
81
|
+
*/
|
|
82
|
+
export function adaptBackdropValue(value, surface) {
|
|
83
|
+
if (!surface || !adaptable(value))
|
|
84
|
+
return value;
|
|
85
|
+
const rgb = readCssColor(value);
|
|
86
|
+
return rgb ? toHexColor(adaptToSurface(rgb, surface)) : value;
|
|
87
|
+
}
|
|
88
|
+
/** Treść na jasnym tle sekcji. Prymitywy nie zmieniają się z motywem, więc wybór jest absolutny. */
|
|
89
|
+
const DARK_CONTENT = 'var(--kpt-color-neutral-900)';
|
|
90
|
+
/** Treść na ciemnym tle sekcji. */
|
|
91
|
+
const LIGHT_CONTENT = 'var(--kpt-color-neutral-50)';
|
|
92
|
+
/**
|
|
93
|
+
* Ton i kolor treści dobrane do tła, które sekcja naprawdę maluje.
|
|
94
|
+
*
|
|
95
|
+
* Host ustawia `color` tokenem powierzchni strony, ale sekcja z własnym `background` przestaje być
|
|
96
|
+
* tą powierzchnią — i wtedy token opisuje sąsiada, nie to, na czym treść leży. Bez tego rachunku
|
|
97
|
+
* ciemne tło w motywie jasnym daje czarny napis na czarnym.
|
|
98
|
+
*
|
|
99
|
+
* Ton wychodzi też na zewnątrz atrybutem, bo o kolor treści dopomina się nie tylko sam napis:
|
|
100
|
+
* przyciski i inne komponenty w sekcji potrzebują kompletu tokenów pod to tło, a tego nie da się
|
|
101
|
+
* odziedziczyć po `color`.
|
|
102
|
+
*/
|
|
103
|
+
export function readBackdropInk(scope, background) {
|
|
104
|
+
if (!background.trim())
|
|
105
|
+
return { tone: '', color: '' };
|
|
106
|
+
const rgb = readCssColor(resolveCssValue(scope, background));
|
|
107
|
+
if (!rgb)
|
|
108
|
+
return { tone: '', color: '' };
|
|
109
|
+
return prefersDarkContent(rgb)
|
|
110
|
+
? { tone: 'light', color: DARK_CONTENT }
|
|
111
|
+
: { tone: 'dark', color: LIGHT_CONTENT };
|
|
112
|
+
}
|
|
113
|
+
/** Atrybuty, którymi przestawia się tokeny: motyw atrybutowy, klasowy i nadpisanie inline. */
|
|
114
|
+
const THEME_ATTRIBUTES = ['data-theme', 'class', 'style'];
|
|
115
|
+
/**
|
|
116
|
+
* Nasłuch na wszystkim, co może przestawić tokeny pod hostem. Zwraca funkcję odpinającą.
|
|
117
|
+
*
|
|
118
|
+
* Motyw bywa zakresowy — `[data-theme="dark"]` nie jest przywiązany do `:root`, więc tokeny
|
|
119
|
+
* przestawia dowolny przodek. Łańcuch od hosta w górę jest krótki i znany, więc obserwujemy go
|
|
120
|
+
* w całości, zamiast puszczać `subtree` na dokumencie, gdzie trafieniem byłaby każda zmiana klasy
|
|
121
|
+
* w aplikacji. Pętla kończy się na `<html>`, bo rodzicem `<html>` jest dokument, nie element.
|
|
122
|
+
*
|
|
123
|
+
* Do tego zapytanie o preferencję systemu: aplikacja może trzymać ciemne tokeny w media query
|
|
124
|
+
* zamiast w atrybucie i wtedy nie zmienia się nic, co da się zaobserwować w DOM.
|
|
125
|
+
*
|
|
126
|
+
* Łańcuch przodków jest brany w chwili zapisu — przeniesienie hosta w inne miejsce drzewa wymaga
|
|
127
|
+
* ponownego zapisu. Komponent takiego przypadku nie obsługuje.
|
|
128
|
+
*
|
|
129
|
+
* Nasłuch trafia też w zmiany niezwiązane z barwą (klasa nakładki na `<html>`, dowolne nadpisanie
|
|
130
|
+
* inline), więc wywołujący ma porównać `readBackdropLead` z poprzednim odczytem, zanim cokolwiek
|
|
131
|
+
* przebuduje.
|
|
132
|
+
*/
|
|
133
|
+
export function observeBackdropTheme(host, onChange) {
|
|
134
|
+
if (typeof MutationObserver === 'undefined')
|
|
135
|
+
return () => undefined;
|
|
136
|
+
const observer = new MutationObserver(onChange);
|
|
137
|
+
for (let node = host; node; node = node.parentElement) {
|
|
138
|
+
observer.observe(node, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
|
|
139
|
+
}
|
|
140
|
+
const scheme = matchMedia('(prefers-color-scheme: dark)');
|
|
141
|
+
scheme.addEventListener('change', onChange);
|
|
142
|
+
return () => {
|
|
143
|
+
observer.disconnect();
|
|
144
|
+
scheme.removeEventListener('change', onChange);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Zamienia wejścia koloru na wartości, które rozumie renderer.
|
|
149
|
+
*
|
|
150
|
+
* Canvas nie rozwija `var()`, więc token trzeba odczytać z wyliczonego stylu elementu — komponent
|
|
151
|
+
* jest jedynym miejscem, które wie, z którego. Pusta rampa bierze kolor wiodący i dwie barwy
|
|
152
|
+
* wyprowadzone z niego obrotem odcienia: paleta biblioteki jest neutralna, więc sięganie po tokeny
|
|
153
|
+
* statusowe (`info`, `success`) byłoby użyciem semantyki do dekoracji.
|
|
154
|
+
*
|
|
155
|
+
* `adaptive` przenosi barwy wpisane na sztywno na powierzchnię bieżącego motywu — czerń wpisana
|
|
156
|
+
* na białe tło staje się bielą, gdy tło jest ciemne. Dotyczy wyłącznie literałów: puste wejście
|
|
157
|
+
* bierze token, a zapis z `var()` sam sięga po token i jedno i drugie już idzie za motywem.
|
|
158
|
+
*
|
|
159
|
+
* Rampa podana wprost przechodzi tą samą drogą co domyślna: każdy wpis najpierw rozwiązujemy
|
|
160
|
+
* względem drzewa (`resolveCssValue`), a potem pytamy przeglądarkę o jego znaczenie. Działa więc
|
|
161
|
+
* i `oklch()`, i `color-mix()`, i nazwa własna, i odwołanie do tokenu — a `color-mix()` z tokenem
|
|
162
|
+
* w środku daje rampę, która sama idzie za motywem. Dalej, do renderera, trafia już sam hex.
|
|
163
|
+
* Wpisy, których przeglądarka nie przyjęła, wracają w `invalid` — rdzeń nie ma własnego trybu dev,
|
|
164
|
+
* więc ostrzega dopiero port. Gdy nie przeszedł żaden, schodzimy do rampy domyślnej: pusty kadr
|
|
165
|
+
* nie powiedziałby nikomu nic.
|
|
166
|
+
*
|
|
167
|
+
* `scope` musi być potomkiem hosta — powód przy `resolveCssValue`.
|
|
168
|
+
*
|
|
169
|
+
* Mieszka w rdzeniu, bo oba porty muszą rozstrzygać to samo tak samo — rozjazd znaczyłby, że ta
|
|
170
|
+
* sama sekcja ma inne barwy w Angularze niż w Reakcie.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveBackdropColors(scope, color, colors, adaptive = false) {
|
|
173
|
+
// Powierzchnię czytamy raz na przebudowę: każdy odczyt to zapis sondy i przeliczenie kaskady.
|
|
174
|
+
const surface = adaptive ? readBackdropSurface(scope) : null;
|
|
175
|
+
const adapt = (value) => (adaptive ? adaptBackdropValue(value, surface) : value);
|
|
176
|
+
const lead = readBackdropLead(scope, adapt(color));
|
|
177
|
+
const explicit = colors.filter((entry) => entry.trim().length > 0);
|
|
178
|
+
const parsed = [];
|
|
179
|
+
const invalid = [];
|
|
180
|
+
for (const entry of explicit) {
|
|
181
|
+
const rgb = readCssColor(resolveCssValue(scope, adapt(entry)));
|
|
182
|
+
if (rgb)
|
|
183
|
+
parsed.push(toHexColor(rgb));
|
|
184
|
+
else
|
|
185
|
+
invalid.push(entry);
|
|
186
|
+
}
|
|
187
|
+
if (parsed.length > 0)
|
|
188
|
+
return { color: lead, colors: parsed, invalid };
|
|
189
|
+
const rgb = readCssColor(lead);
|
|
190
|
+
if (!rgb)
|
|
191
|
+
return { color: lead, colors: [], invalid };
|
|
192
|
+
return {
|
|
193
|
+
color: lead,
|
|
194
|
+
colors: [
|
|
195
|
+
toHexColor(shiftHue(rgb, -KPT_BACKDROP_RAMP_SPREAD)),
|
|
196
|
+
toHexColor(rgb),
|
|
197
|
+
toHexColor(shiftHue(rgb, KPT_BACKDROP_RAMP_SPREAD)),
|
|
198
|
+
],
|
|
199
|
+
invalid,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/** Renderer, który nic nie rysuje — dla środowisk bez DOM i dla pustej rampy barw. */
|
|
203
|
+
const IDLE = {
|
|
204
|
+
resize: () => undefined,
|
|
205
|
+
draw: () => undefined,
|
|
206
|
+
dispose: () => undefined,
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Drobna siatka migoczących kwadratów. Cała matematyka siedzi w `signal-grid.ts`;
|
|
210
|
+
* tutaj zostaje pętla po komórkach.
|
|
211
|
+
*/
|
|
212
|
+
export function createSignalGridRenderer(options) {
|
|
213
|
+
let width = 0;
|
|
214
|
+
let height = 0;
|
|
215
|
+
let grid = null;
|
|
216
|
+
const color = options.color ?? '#ffffff';
|
|
217
|
+
return {
|
|
218
|
+
resize(nextWidth, nextHeight) {
|
|
219
|
+
width = nextWidth;
|
|
220
|
+
height = nextHeight;
|
|
221
|
+
grid = buildSignalGrid({
|
|
222
|
+
width,
|
|
223
|
+
height,
|
|
224
|
+
cell: options.cell,
|
|
225
|
+
gap: options.gap,
|
|
226
|
+
anchor: options.anchor,
|
|
227
|
+
density: options.density,
|
|
228
|
+
falloff: options.falloff,
|
|
229
|
+
seed: options.seed,
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
draw(context, time) {
|
|
233
|
+
if (!grid)
|
|
234
|
+
return;
|
|
235
|
+
context.clearRect(0, 0, width, height);
|
|
236
|
+
context.fillStyle = color;
|
|
237
|
+
for (const cell of grid.cells) {
|
|
238
|
+
const alpha = signalGridAlpha(cell, time);
|
|
239
|
+
if (alpha < ALPHA_CUTOFF)
|
|
240
|
+
continue;
|
|
241
|
+
context.globalAlpha = alpha;
|
|
242
|
+
context.fillRect(cell.x, cell.y, cell.size, cell.size);
|
|
243
|
+
}
|
|
244
|
+
context.globalAlpha = 1;
|
|
245
|
+
},
|
|
246
|
+
dispose() {
|
|
247
|
+
grid = null;
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Jedna miękka wstęga światła z barwami przesuwającymi się wzdłuż niej.
|
|
253
|
+
*
|
|
254
|
+
* Liczymy ją w buforze o dłuższym boku `KPT_WAVE_RESOLUTION` i skalujemy w górę — miękkość
|
|
255
|
+
* bierze się z interpolacji przeglądarki, więc kilkanaście tysięcy pikseli na klatkę wystarcza
|
|
256
|
+
* na dowolnie duży kadr. Rampa barw idzie przez podręczną tablicę: mieszanie po kole OKLCH raz
|
|
257
|
+
* na piksel byłoby kilkoma pierwiastkami i arkusem tangensa na każdy z nich.
|
|
258
|
+
*/
|
|
259
|
+
export function createWaveRenderer(options) {
|
|
260
|
+
const ramp = (options.colors ?? []).map(parseHexColor).filter((color) => color !== null);
|
|
261
|
+
if (ramp.length === 0 || typeof document === 'undefined')
|
|
262
|
+
return IDLE;
|
|
263
|
+
const lut = new Uint8ClampedArray(LUT_STOPS * 3);
|
|
264
|
+
for (let stop = 0; stop < LUT_STOPS; stop += 1) {
|
|
265
|
+
const { r, g, b } = mixOklch(ramp, stop / LUT_STOPS);
|
|
266
|
+
lut[stop * 3] = r;
|
|
267
|
+
lut[stop * 3 + 1] = g;
|
|
268
|
+
lut[stop * 3 + 2] = b;
|
|
269
|
+
}
|
|
270
|
+
const buffer = document.createElement('canvas');
|
|
271
|
+
const bufferContext = buffer.getContext('2d');
|
|
272
|
+
let width = 0;
|
|
273
|
+
let height = 0;
|
|
274
|
+
let cols = 0;
|
|
275
|
+
let rows = 0;
|
|
276
|
+
let image = null;
|
|
277
|
+
let settings = null;
|
|
278
|
+
return {
|
|
279
|
+
resize(nextWidth, nextHeight) {
|
|
280
|
+
width = nextWidth;
|
|
281
|
+
height = nextHeight;
|
|
282
|
+
const size = waveBuffer(width, height);
|
|
283
|
+
cols = size.cols;
|
|
284
|
+
rows = size.rows;
|
|
285
|
+
if (!bufferContext || cols === 0 || rows === 0) {
|
|
286
|
+
image = null;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
buffer.width = cols;
|
|
290
|
+
buffer.height = rows;
|
|
291
|
+
image = bufferContext.createImageData(cols, rows);
|
|
292
|
+
settings = resolveWave({
|
|
293
|
+
width,
|
|
294
|
+
height,
|
|
295
|
+
rotation: options.rotation,
|
|
296
|
+
band: options.band,
|
|
297
|
+
amplitude: options.amplitude,
|
|
298
|
+
seed: options.seed,
|
|
299
|
+
});
|
|
300
|
+
},
|
|
301
|
+
draw(context, time) {
|
|
302
|
+
if (!image || !bufferContext || !settings)
|
|
303
|
+
return;
|
|
304
|
+
const data = image.data;
|
|
305
|
+
let offset = 0;
|
|
306
|
+
for (let row = 0; row < rows; row += 1) {
|
|
307
|
+
// Próbkujemy środek komórki bufora, nie jej róg — inaczej obraz przesuwałby się
|
|
308
|
+
// o pół piksela przy każdej zmianie rozdzielczości.
|
|
309
|
+
const v = (row + 0.5) / rows;
|
|
310
|
+
for (let col = 0; col < cols; col += 1) {
|
|
311
|
+
const u = (col + 0.5) / cols;
|
|
312
|
+
const stop = Math.min(LUT_STOPS - 1, (waveRamp(u, v, time, settings) * LUT_STOPS) | 0) * 3;
|
|
313
|
+
data[offset] = lut[stop];
|
|
314
|
+
data[offset + 1] = lut[stop + 1];
|
|
315
|
+
data[offset + 2] = lut[stop + 2];
|
|
316
|
+
// Barwę piszemy także tam, gdzie krycie jest zerowe: przy skalowaniu w górę
|
|
317
|
+
// przeglądarka interpoluje wszystkie cztery składowe i zostawiony śmieć
|
|
318
|
+
// wypłynąłby na krawędziach wstęgi.
|
|
319
|
+
data[offset + 3] = waveIntensity(u, v, time, settings) * 255;
|
|
320
|
+
offset += 4;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
bufferContext.putImageData(image, 0, 0);
|
|
324
|
+
context.clearRect(0, 0, width, height);
|
|
325
|
+
context.imageSmoothingEnabled = true;
|
|
326
|
+
context.imageSmoothingQuality = 'high';
|
|
327
|
+
context.drawImage(buffer, 0, 0, width, height);
|
|
328
|
+
},
|
|
329
|
+
dispose() {
|
|
330
|
+
image = null;
|
|
331
|
+
settings = null;
|
|
332
|
+
// Zerowy rozmiar zwalnia pamięć bufora od razu, bez czekania na zbieracza.
|
|
333
|
+
buffer.width = 0;
|
|
334
|
+
buffer.height = 0;
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
/** Renderer wskazanego efektu. Nieznany efekt nie rysuje niczego, zamiast wywracać klatkę. */
|
|
339
|
+
export function createBackdropRenderer(options) {
|
|
340
|
+
switch (options.effect ?? 'signal-grid') {
|
|
341
|
+
case 'signal-grid':
|
|
342
|
+
return createSignalGridRenderer(options);
|
|
343
|
+
case 'wave':
|
|
344
|
+
return createWaveRenderer(options);
|
|
345
|
+
default:
|
|
346
|
+
return IDLE;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { KptSignalGridAlphaOptions, KptSignalGridCell, KptSignalGridFrame, KptSignalGridOptions } from './types.ts';
|
|
2
|
+
/** Bok kwadratu (px). */
|
|
3
|
+
export declare const KPT_SIGNAL_GRID_CELL = 5;
|
|
4
|
+
/** Odstęp między kwadratami (px). */
|
|
5
|
+
export declare const KPT_SIGNAL_GRID_GAP = 11;
|
|
6
|
+
/** Udział zapalonych komórek przy kotwicy. */
|
|
7
|
+
export declare const KPT_SIGNAL_GRID_DENSITY = 0.8;
|
|
8
|
+
/** Wykładnik wygaszania ku przeciwnej krawędzi. */
|
|
9
|
+
export declare const KPT_SIGNAL_GRID_FALLOFF = 1.4;
|
|
10
|
+
/** Krycie komórki w dołku migotania. */
|
|
11
|
+
export declare const KPT_SIGNAL_GRID_FLOOR = 0.18;
|
|
12
|
+
/** Radiany na sekundę przy `speed = 1` — pełny cykl komórki to wtedy około siedmiu sekund. */
|
|
13
|
+
export declare const KPT_SIGNAL_GRID_RATE = 0.9;
|
|
14
|
+
/**
|
|
15
|
+
* Rampa gęstości: udział zapalonych komórek w odległości `distance` od kotwicy (0 przy kotwicy,
|
|
16
|
+
* 1 przy przeciwnej krawędzi). To ona robi cały efekt — gradient bierze się z tego, ile kwadratów
|
|
17
|
+
* się pali, nie z przezroczystości nałożonej na równomierną siatkę.
|
|
18
|
+
*/
|
|
19
|
+
export declare function signalGridDensity(distance: number, density: number, falloff: number): number;
|
|
20
|
+
/**
|
|
21
|
+
* Buduje siatkę pod zadany obszar. Liczy się raz — przy starcie i przy zmianie rozmiaru — bo
|
|
22
|
+
* geometria i losowania komórek są w czasie stałe; w klatce zmienia się wyłącznie krycie.
|
|
23
|
+
*
|
|
24
|
+
* Siatka celowo wychodzi poza obszar: liczba kolumn zaokrągla się w górę, a nadmiar rozkłada się
|
|
25
|
+
* po równo na obie strony. Siatka kończąca się przed krawędzią wygląda na uciętą.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildSignalGrid(options: KptSignalGridOptions): KptSignalGridFrame;
|
|
28
|
+
/**
|
|
29
|
+
* Krycie komórki w chwili `time` (sekundy). Sinus przepuszczony przez wygładzenie — surowy
|
|
30
|
+
* zostawiałby komórki najdłużej w połowie jasności, a migotanie ma przystawać na końcach.
|
|
31
|
+
*
|
|
32
|
+
* `speed` równe zeru zatrzymuje obraz, nie gasi go: każda komórka zostaje na swojej fazie,
|
|
33
|
+
* więc statyczna klatka (ruch ograniczony w systemie) wciąż ma zróżnicowaną jasność.
|
|
34
|
+
*/
|
|
35
|
+
export declare function signalGridAlpha(cell: KptSignalGridCell, time: number, options?: KptSignalGridAlphaOptions): number;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { kptHash01 } from "./hash.js";
|
|
2
|
+
/** Bok kwadratu (px). */
|
|
3
|
+
export const KPT_SIGNAL_GRID_CELL = 5;
|
|
4
|
+
/** Odstęp między kwadratami (px). */
|
|
5
|
+
export const KPT_SIGNAL_GRID_GAP = 11;
|
|
6
|
+
/** Udział zapalonych komórek przy kotwicy. */
|
|
7
|
+
export const KPT_SIGNAL_GRID_DENSITY = 0.8;
|
|
8
|
+
/** Wykładnik wygaszania ku przeciwnej krawędzi. */
|
|
9
|
+
export const KPT_SIGNAL_GRID_FALLOFF = 1.4;
|
|
10
|
+
/** Krycie komórki w dołku migotania. */
|
|
11
|
+
export const KPT_SIGNAL_GRID_FLOOR = 0.18;
|
|
12
|
+
/** Radiany na sekundę przy `speed = 1` — pełny cykl komórki to wtedy około siedmiu sekund. */
|
|
13
|
+
export const KPT_SIGNAL_GRID_RATE = 0.9;
|
|
14
|
+
/** Szczytowe krycie komórki przy przeciwnej krawędzi — tam, gdzie rampa już nic nie daje. */
|
|
15
|
+
const MIN_PRESENCE = 0.35;
|
|
16
|
+
const TAU = Math.PI * 2;
|
|
17
|
+
const clamp01 = (value) => (value < 0 ? 0 : value > 1 ? 1 : value);
|
|
18
|
+
/**
|
|
19
|
+
* Rampa gęstości: udział zapalonych komórek w odległości `distance` od kotwicy (0 przy kotwicy,
|
|
20
|
+
* 1 przy przeciwnej krawędzi). To ona robi cały efekt — gradient bierze się z tego, ile kwadratów
|
|
21
|
+
* się pali, nie z przezroczystości nałożonej na równomierną siatkę.
|
|
22
|
+
*/
|
|
23
|
+
export function signalGridDensity(distance, density, falloff) {
|
|
24
|
+
return density * Math.pow(1 - clamp01(distance), Math.max(0, falloff));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Buduje siatkę pod zadany obszar. Liczy się raz — przy starcie i przy zmianie rozmiaru — bo
|
|
28
|
+
* geometria i losowania komórek są w czasie stałe; w klatce zmienia się wyłącznie krycie.
|
|
29
|
+
*
|
|
30
|
+
* Siatka celowo wychodzi poza obszar: liczba kolumn zaokrągla się w górę, a nadmiar rozkłada się
|
|
31
|
+
* po równo na obie strony. Siatka kończąca się przed krawędzią wygląda na uciętą.
|
|
32
|
+
*/
|
|
33
|
+
export function buildSignalGrid(options) {
|
|
34
|
+
const cell = Math.max(1, options.cell ?? KPT_SIGNAL_GRID_CELL);
|
|
35
|
+
const gap = Math.max(0, options.gap ?? KPT_SIGNAL_GRID_GAP);
|
|
36
|
+
const anchor = options.anchor ?? 'right';
|
|
37
|
+
const density = clamp01(options.density ?? KPT_SIGNAL_GRID_DENSITY);
|
|
38
|
+
const falloff = Math.max(0, options.falloff ?? KPT_SIGNAL_GRID_FALLOFF);
|
|
39
|
+
const seed = options.seed ?? 0;
|
|
40
|
+
const width = Math.max(0, options.width);
|
|
41
|
+
const height = Math.max(0, options.height);
|
|
42
|
+
const stride = cell + gap;
|
|
43
|
+
const cols = width > 0 ? Math.ceil((width + gap) / stride) : 0;
|
|
44
|
+
const rows = height > 0 ? Math.ceil((height + gap) / stride) : 0;
|
|
45
|
+
const originX = Math.round((width - (cols * stride - gap)) / 2);
|
|
46
|
+
const originY = Math.round((height - (rows * stride - gap)) / 2);
|
|
47
|
+
const cells = [];
|
|
48
|
+
for (let col = 0; col < cols; col += 1) {
|
|
49
|
+
const span = cols > 1 ? col / (cols - 1) : 0;
|
|
50
|
+
const distance = anchor === 'right' ? 1 - span : span;
|
|
51
|
+
const ramp = Math.pow(1 - distance, falloff);
|
|
52
|
+
const lit = density * ramp;
|
|
53
|
+
// Rampę liczymy raz na kolumnę: w pionie siatka jest jednorodna, więc wnętrze pętli
|
|
54
|
+
// wierszy to już tylko trzy losowania.
|
|
55
|
+
if (lit <= 0)
|
|
56
|
+
continue;
|
|
57
|
+
const presence = MIN_PRESENCE + (1 - MIN_PRESENCE) * ramp;
|
|
58
|
+
for (let row = 0; row < rows; row += 1) {
|
|
59
|
+
if (kptHash01(col, row, seed) >= lit)
|
|
60
|
+
continue;
|
|
61
|
+
cells.push({
|
|
62
|
+
x: originX + col * stride,
|
|
63
|
+
y: originY + row * stride,
|
|
64
|
+
size: cell,
|
|
65
|
+
presence,
|
|
66
|
+
phase: kptHash01(col, row, seed + 101) * TAU,
|
|
67
|
+
// Tempo rozstrzelone w przedziale 0,45–1,55: przy jednym tempie cała siatka
|
|
68
|
+
// pulsowałaby zgodnie i zamiast migotania wyszedłby oddech.
|
|
69
|
+
rate: 0.45 + kptHash01(col, row, seed + 211) * 1.1,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { cols, rows, cell, gap, cells };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Krycie komórki w chwili `time` (sekundy). Sinus przepuszczony przez wygładzenie — surowy
|
|
77
|
+
* zostawiałby komórki najdłużej w połowie jasności, a migotanie ma przystawać na końcach.
|
|
78
|
+
*
|
|
79
|
+
* `speed` równe zeru zatrzymuje obraz, nie gasi go: każda komórka zostaje na swojej fazie,
|
|
80
|
+
* więc statyczna klatka (ruch ograniczony w systemie) wciąż ma zróżnicowaną jasność.
|
|
81
|
+
*/
|
|
82
|
+
export function signalGridAlpha(cell, time, options = {}) {
|
|
83
|
+
const speed = options.speed ?? 1;
|
|
84
|
+
const floor = clamp01(options.floor ?? KPT_SIGNAL_GRID_FLOOR);
|
|
85
|
+
const wave = 0.5 + 0.5 * Math.sin(time * speed * KPT_SIGNAL_GRID_RATE * cell.rate + cell.phase);
|
|
86
|
+
const eased = wave * wave * (3 - 2 * wave);
|
|
87
|
+
return cell.presence * (floor + (1 - floor) * eased);
|
|
88
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Krawędź, przy której siatka jest najgęstsza; ku przeciwnej wygasa do pustki. */
|
|
2
|
+
export type KptBackdropAnchor = 'left' | 'right';
|
|
3
|
+
/** Efekt tła. */
|
|
4
|
+
export type KptBackdropEffect = 'signal-grid' | 'wave';
|
|
5
|
+
/** Ustawienia siatki. Wymiary w pikselach CSS, nie w pikselach urządzenia. */
|
|
6
|
+
export interface KptSignalGridOptions {
|
|
7
|
+
/** Szerokość obszaru rysowania (px CSS). */
|
|
8
|
+
width: number;
|
|
9
|
+
/** Wysokość obszaru rysowania (px CSS). */
|
|
10
|
+
height: number;
|
|
11
|
+
/** Bok kwadratu (px). */
|
|
12
|
+
cell?: number;
|
|
13
|
+
/** Odstęp między kwadratami (px). */
|
|
14
|
+
gap?: number;
|
|
15
|
+
/** Krawędź, przy której siatka jest najgęstsza. */
|
|
16
|
+
anchor?: KptBackdropAnchor;
|
|
17
|
+
/** Udział zapalonych komórek przy kotwicy, 0–1. */
|
|
18
|
+
density?: number;
|
|
19
|
+
/** Jak szybko gęstość spada ku przeciwnej krawędzi; >1 gasi gwałtowniej. */
|
|
20
|
+
falloff?: number;
|
|
21
|
+
/** Ziarno losowania — ta sama wartość daje ten sam układ przy każdym uruchomieniu. */
|
|
22
|
+
seed?: number;
|
|
23
|
+
}
|
|
24
|
+
/** Pojedynczy kwadrat. Geometria i losowania liczą się raz, przy zmianie rozmiaru. */
|
|
25
|
+
export interface KptSignalGridCell {
|
|
26
|
+
/** Lewy górny róg w px CSS; może wypaść poza obszar, bo siatka celowo wychodzi za krawędź. */
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
/** Bok kwadratu (px). */
|
|
30
|
+
size: number;
|
|
31
|
+
/** Szczytowa krycie komórki, 0–1 — spada wraz z gęstością ku przeciwnej krawędzi. */
|
|
32
|
+
presence: number;
|
|
33
|
+
/** Przesunięcie fazy migotania (radiany). */
|
|
34
|
+
phase: number;
|
|
35
|
+
/** Mnożnik tempa migotania — bez niego cała siatka pulsowałaby zgodnie. */
|
|
36
|
+
rate: number;
|
|
37
|
+
}
|
|
38
|
+
/** Gotowa siatka: wymiary w komórkach i lista kwadratów do narysowania. */
|
|
39
|
+
export interface KptSignalGridFrame {
|
|
40
|
+
cols: number;
|
|
41
|
+
rows: number;
|
|
42
|
+
cell: number;
|
|
43
|
+
gap: number;
|
|
44
|
+
cells: readonly KptSignalGridCell[];
|
|
45
|
+
}
|
|
46
|
+
/** Ustawienia wstęgi. Wymiary w pikselach CSS, nie w pikselach urządzenia. */
|
|
47
|
+
export interface KptWaveOptions {
|
|
48
|
+
/** Szerokość obszaru rysowania (px CSS). */
|
|
49
|
+
width: number;
|
|
50
|
+
/** Wysokość obszaru rysowania (px CSS). */
|
|
51
|
+
height: number;
|
|
52
|
+
/** Kąt, pod jakim prąd przecina kadr (stopnie); 0 to poziomo w prawo. */
|
|
53
|
+
rotation?: number;
|
|
54
|
+
/** Szerokość wstęgi jako ułamek wysokości kadru. */
|
|
55
|
+
band?: number;
|
|
56
|
+
/** Wykładnik opadania od linii środkowej; 2 to krzywa Gaussa, więcej spłaszcza rdzeń. */
|
|
57
|
+
softness?: number;
|
|
58
|
+
/** Jak mocno faluje linia środkowa, w ułamkach wysokości kadru. */
|
|
59
|
+
amplitude?: number;
|
|
60
|
+
/** Ile rampy barw przypada na jednostkę długości wzdłuż wstęgi. */
|
|
61
|
+
spread?: number;
|
|
62
|
+
/** Ile rampy barw przesuwa się na sekundę. */
|
|
63
|
+
drift?: number;
|
|
64
|
+
/** Ziarno losowania — ta sama wartość daje ten sam przebieg falowania. */
|
|
65
|
+
seed?: number;
|
|
66
|
+
}
|
|
67
|
+
/** Jedna składowa linii środkowej wstęgi. */
|
|
68
|
+
export interface KptWaveHarmonic {
|
|
69
|
+
frequency: number;
|
|
70
|
+
rate: number;
|
|
71
|
+
phase: number;
|
|
72
|
+
weight: number;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Ustawienia rozłożone na wartości gotowe do odczytu w pętli po pikselach.
|
|
76
|
+
* Powstają raz, przy zmianie wejść albo rozmiaru — nie na każdą klatkę.
|
|
77
|
+
*/
|
|
78
|
+
export interface KptWaveSettings {
|
|
79
|
+
/** Proporcja kadru; bez niej obrót skośnie ściskałby wstęgę. */
|
|
80
|
+
aspect: number;
|
|
81
|
+
cos: number;
|
|
82
|
+
sin: number;
|
|
83
|
+
band: number;
|
|
84
|
+
softness: number;
|
|
85
|
+
amplitude: number;
|
|
86
|
+
spread: number;
|
|
87
|
+
drift: number;
|
|
88
|
+
harmonics: readonly KptWaveHarmonic[];
|
|
89
|
+
}
|
|
90
|
+
/** Ustawienia krycia w czasie. */
|
|
91
|
+
export interface KptSignalGridAlphaOptions {
|
|
92
|
+
/** Mnożnik tempa — 1 to domyślne tempo `KPT_SIGNAL_GRID_RATE`. */
|
|
93
|
+
speed?: number;
|
|
94
|
+
/** Krycie komórki w dołku migotania, 0–1. Zero gasi ją do końca. */
|
|
95
|
+
floor?: number;
|
|
96
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/wave.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { KptWaveOptions, KptWaveSettings } from './types.ts';
|
|
2
|
+
/** Dłuższy bok bufora, w którym liczy się efekt (px). */
|
|
3
|
+
export declare const KPT_WAVE_RESOLUTION = 160;
|
|
4
|
+
/** Najmniejsza liczba próbek na bok bufora — chyba że sam obszar jest mniejszy. */
|
|
5
|
+
export declare const KPT_WAVE_MIN_SIDE = 72;
|
|
6
|
+
/** Szerokość wstęgi jako ułamek wysokości kadru. */
|
|
7
|
+
export declare const KPT_WAVE_BAND = 0.34;
|
|
8
|
+
/** Wykładnik opadania od linii środkowej; 2 to krzywa Gaussa. */
|
|
9
|
+
export declare const KPT_WAVE_SOFTNESS = 2;
|
|
10
|
+
/** Jak mocno faluje linia środkowa, w ułamkach wysokości kadru. */
|
|
11
|
+
export declare const KPT_WAVE_AMPLITUDE = 0.16;
|
|
12
|
+
/** Kąt, pod jakim prąd przecina kadr (stopnie). */
|
|
13
|
+
export declare const KPT_WAVE_ROTATION = 12;
|
|
14
|
+
/** Ile rampy barw przypada na jednostkę długości wzdłuż wstęgi. */
|
|
15
|
+
export declare const KPT_WAVE_SPREAD = 0.55;
|
|
16
|
+
/** Ile rampy barw przesuwa się na sekundę przy `speed = 1`. */
|
|
17
|
+
export declare const KPT_WAVE_DRIFT = 0.07;
|
|
18
|
+
/** Wymiary bufora, w którym efekt jest liczony. */
|
|
19
|
+
export interface KptWaveBuffer {
|
|
20
|
+
cols: number;
|
|
21
|
+
rows: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Wymiary bufora pod zadany obszar.
|
|
25
|
+
*
|
|
26
|
+
* Wstęga nie ma detalu — jest samą miękkością — więc liczenie jej w pełnej rozdzielczości canvasu
|
|
27
|
+
* byłoby płaceniem za piksele, które i tak zostaną rozmyte. Bufor o dłuższym boku
|
|
28
|
+
* `KPT_WAVE_RESOLUTION` daje kilkanaście tysięcy pikseli na klatkę, a rozmycie robi za darmo
|
|
29
|
+
* interpolacja przeglądarki przy skalowaniu go w górę.
|
|
30
|
+
*
|
|
31
|
+
* Krótszy bok ma osobną podłogę `KPT_WAVE_MIN_SIDE`. W szerokim kadrze proporcjonalne skalowanie
|
|
32
|
+
* zostawiłoby kilkadziesiąt wierszy, a po obróceniu wstęgi o kąt bliski prostemu krzywa biegłaby
|
|
33
|
+
* właśnie wzdłuż tej osi i zaczynała się łamać. Bufor nie musi trzymać proporcji kadru —
|
|
34
|
+
* próbkowanie chodzi po współrzędnych znormalizowanych, a geometrię niesie `aspect` w ustawieniach.
|
|
35
|
+
*/
|
|
36
|
+
export declare function waveBuffer(width: number, height: number, resolution?: number): KptWaveBuffer;
|
|
37
|
+
/**
|
|
38
|
+
* Rozkłada ustawienia na wartości, które w pętli po pikselach są już tylko odczytywane.
|
|
39
|
+
*
|
|
40
|
+
* Domyślne wartości, sinus i cosinus kąta oraz harmoniczne liczą się raz — przy zmianie wejść
|
|
41
|
+
* albo rozmiaru — bo inaczej szłyby kilkanaście tysięcy razy na klatkę.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveWave(options: KptWaveOptions): KptWaveSettings;
|
|
44
|
+
/**
|
|
45
|
+
* Jasność wstęgi w punkcie `(u, v)` znormalizowanego kadru, w chwili `time` (sekundy).
|
|
46
|
+
*
|
|
47
|
+
* Linia środkowa to suma harmonicznych o niewspółmiernych częstotliwościach — stąd prąd faluje
|
|
48
|
+
* bez powtarzalnego wzoru. Od niej jasność opada krzywą Gaussa, więc w całym kadrze nie ma ani
|
|
49
|
+
* jednej twardej krawędzi; to właśnie odróżnia prąd światła od paska.
|
|
50
|
+
*/
|
|
51
|
+
export declare function waveIntensity(u: number, v: number, time: number, settings: KptWaveSettings): number;
|
|
52
|
+
/**
|
|
53
|
+
* Miejsce na rampie barw (0–1) dla punktu `(u, v)` w chwili `time`.
|
|
54
|
+
*
|
|
55
|
+
* Barwy przesuwają się wzdłuż wstęgi, nie w poprzek — dlatego liczy się tylko współrzędna `s`.
|
|
56
|
+
* Wynik zawsze zawija się do 0–1, więc rosnący czas nie wyprowadza go poza rampę.
|
|
57
|
+
*/
|
|
58
|
+
export declare function waveRamp(u: number, v: number, time: number, settings: KptWaveSettings): number;
|