@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.
@@ -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
+ }
@@ -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;