@promptctl/rich-js 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/color.d.ts +9 -0
- package/dist/core/color.js +12 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/renderables/effect.d.ts +37 -22
- package/dist/renderables/effect.js +57 -37
- package/dist/renderables/effects.d.ts +69 -34
- package/dist/renderables/effects.js +175 -141
- package/package.json +1 -1
package/dist/core/color.d.ts
CHANGED
|
@@ -259,6 +259,15 @@ export declare class ColorSpec {
|
|
|
259
259
|
* on a ground (`flattenAlpha`).
|
|
260
260
|
*/
|
|
261
261
|
static matchOn(value: ColorRgba, depth: ColorDepth, theme: TerminalTheme, foreground: boolean): ColorSpec;
|
|
262
|
+
/**
|
|
263
|
+
* The spec a slot is written in once its colour moves from `was`, what its
|
|
264
|
+
* written spec draws on `theme`, to the opaque `now`: `matchOn`'s — or
|
|
265
|
+
* none, where the written spec is as near `now` as that match. A move the
|
|
266
|
+
* depth cannot show is no move, so below truecolor a colour moved a little
|
|
267
|
+
* off any colour the depth can write — a theme slot, the default colour,
|
|
268
|
+
* the cube — keeps the spec it was written in.
|
|
269
|
+
*/
|
|
270
|
+
static matchMove(was: ColorRgba, now: ColorRgba, depth: ColorDepth, theme: TerminalTheme, foreground: boolean): ColorSpec | undefined;
|
|
262
271
|
/**
|
|
263
272
|
* Parse a color string. Memoized (`Memo`): a string parsed again while
|
|
264
273
|
* remembered returns the same instance.
|
package/dist/core/color.js
CHANGED
|
@@ -624,6 +624,18 @@ export class ColorSpec {
|
|
|
624
624
|
];
|
|
625
625
|
return candidates.reduce((best, next) => (rgbDistance(next[1], value) < rgbDistance(best[1], value) ? next : best))[0];
|
|
626
626
|
}
|
|
627
|
+
/**
|
|
628
|
+
* The spec a slot is written in once its colour moves from `was`, what its
|
|
629
|
+
* written spec draws on `theme`, to the opaque `now`: `matchOn`'s — or
|
|
630
|
+
* none, where the written spec is as near `now` as that match. A move the
|
|
631
|
+
* depth cannot show is no move, so below truecolor a colour moved a little
|
|
632
|
+
* off any colour the depth can write — a theme slot, the default colour,
|
|
633
|
+
* the cube — keeps the spec it was written in.
|
|
634
|
+
*/
|
|
635
|
+
static matchMove(was, now, depth, theme, foreground) {
|
|
636
|
+
const match = ColorSpec.matchOn(now, depth, theme, foreground);
|
|
637
|
+
return rgbDistance(was, now) <= rgbDistance(match.getTruecolor(theme, foreground), now) ? undefined : match;
|
|
638
|
+
}
|
|
627
639
|
/**
|
|
628
640
|
* Parse a color string. Memoized (`Memo`): a string parsed again while
|
|
629
641
|
* remembered returns the same instance.
|
package/dist/index.d.ts
CHANGED
|
@@ -57,8 +57,8 @@ export type { Env } from "./core/env.js";
|
|
|
57
57
|
export { Constrain } from "./renderables/constrain.js";
|
|
58
58
|
export { Effected, confined, drawnOf } from "./renderables/effect.js";
|
|
59
59
|
export type { CellColors, Drawn, Effect, EffectCell, EffectedOptions, Pair } from "./renderables/effect.js";
|
|
60
|
-
export { EFFECT_CURVES, EFFECT_LIGHTS,
|
|
61
|
-
export type { Curve, Field, Loop, Touch, Transition } from "./renderables/effects.js";
|
|
60
|
+
export { EFFECT_CATALOGUE, EFFECT_CURVES, EFFECT_LIGHTS, dissolveOut, fadeIn, onColors, pulse, shimmer, sparkle, wheel, } from "./renderables/effects.js";
|
|
61
|
+
export type { Curve, EffectTarget, Field, Loop, LoopName, Touch, Transition, TransitionName } from "./renderables/effects.js";
|
|
62
62
|
export { Align } from "./renderables/align.js";
|
|
63
63
|
export type { Alignment } from "./core/place.js";
|
|
64
64
|
export { Padding } from "./renderables/padding.js";
|
package/dist/index.js
CHANGED
|
@@ -70,7 +70,7 @@ export { Console } from "./core/console.js";
|
|
|
70
70
|
// Renderables
|
|
71
71
|
export { Constrain } from "./renderables/constrain.js";
|
|
72
72
|
export { Effected, confined, drawnOf } from "./renderables/effect.js";
|
|
73
|
-
export { EFFECT_CURVES, EFFECT_LIGHTS,
|
|
73
|
+
export { EFFECT_CATALOGUE, EFFECT_CURVES, EFFECT_LIGHTS, dissolveOut, fadeIn, onColors, pulse, shimmer, sparkle, wheel, } from "./renderables/effects.js";
|
|
74
74
|
export { Align } from "./renderables/align.js";
|
|
75
75
|
export { Padding } from "./renderables/padding.js";
|
|
76
76
|
export { Viewport, ViewportPerKey, SCROLLBAR } from "./renderables/viewport.js";
|
|
@@ -2,16 +2,19 @@
|
|
|
2
2
|
* Effected — a renderable whose finished cells' colours pass through an
|
|
3
3
|
* effect at a moment `t`.
|
|
4
4
|
*
|
|
5
|
-
* An effect is one pure function: a
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* An effect is one pure function in two stages: a time and what the element
|
|
6
|
+
* draws in that frame in, a function of the frame out; and into that, a
|
|
7
|
+
* cell's colours and where the cell is, the cell's new colours out. Whatever
|
|
8
|
+
* holds for the whole frame is worked out in the first stage, once, and every
|
|
9
|
+
* cell of the frame is handed to the second. Shimmer, pulse, fade and
|
|
10
|
+
* dissolve are all this one shape, differing by what the function does with
|
|
11
|
+
* its inputs [LAW:one-type-per-behavior]; two effects stack by nesting one
|
|
12
|
+
* wrapper in another, which is the same as calling one after the other but
|
|
13
|
+
* for what the outer one measures: the inner one's output.
|
|
11
14
|
*
|
|
12
15
|
* What the element draws (`Drawn`) is measured here, in the render, off the
|
|
13
|
-
* same reading of each segment its cells are drawn from, and handed to
|
|
14
|
-
*
|
|
16
|
+
* same reading of each segment its cells are drawn from, and handed to the
|
|
17
|
+
* effect's first stage, once a frame. It is what an effect keeping words legible is
|
|
15
18
|
* settled against (`onColors`, `./effects.ts`), so no caller derives it a
|
|
16
19
|
* second time from a render of its own.
|
|
17
20
|
*
|
|
@@ -81,11 +84,19 @@ export interface EffectCell {
|
|
|
81
84
|
readonly anchor: Anchor | undefined;
|
|
82
85
|
}
|
|
83
86
|
/**
|
|
84
|
-
* A cell's ink and ground as the screen shows them,
|
|
85
|
-
*
|
|
87
|
+
* A cell's ink and ground as the screen shows them, which of the two are the
|
|
88
|
+
* terminal's, and whether its style swaps them (`reverse`): what an `Effect`
|
|
89
|
+
* is handed for that cell.
|
|
86
90
|
*/
|
|
87
91
|
export interface Pair extends CellColors {
|
|
88
92
|
readonly terminal: EffectCell["terminal"];
|
|
93
|
+
/**
|
|
94
|
+
* Whether the ink is written in the style's ground slot and the ground in
|
|
95
|
+
* its ink slot. Below truecolor the slot decides what a moved colour can
|
|
96
|
+
* land on: a slot set to `default` draws the terminal's foreground in one
|
|
97
|
+
* and its background in the other.
|
|
98
|
+
*/
|
|
99
|
+
readonly reverse: boolean;
|
|
89
100
|
}
|
|
90
101
|
/**
|
|
91
102
|
* The colours the element an effect wraps draws its words in, in one frame,
|
|
@@ -107,10 +118,13 @@ export interface Drawn {
|
|
|
107
118
|
readonly theme: TerminalTheme;
|
|
108
119
|
}
|
|
109
120
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
121
|
+
* The frame at time `t`, in seconds, whose element draws `drawn`: each cell's
|
|
122
|
+
* colours in that frame. Return `colors` to leave a cell be.
|
|
123
|
+
* [LAW:types-are-the-program] Per-frame work is the outer stage, so it runs
|
|
124
|
+
* once a frame by the shape of the call, never by a cache asking whether this
|
|
125
|
+
* cell's frame is the last one's.
|
|
112
126
|
*/
|
|
113
|
-
export type Effect = (
|
|
127
|
+
export type Effect = (t: number, drawn: Drawn) => (colors: CellColors, cell: EffectCell) => CellColors;
|
|
114
128
|
export interface EffectedOptions {
|
|
115
129
|
/** The moment to draw, in seconds. */
|
|
116
130
|
readonly t: number;
|
|
@@ -138,17 +152,18 @@ export declare class Effected implements Renderable, Measurable {
|
|
|
138
152
|
minimum: number;
|
|
139
153
|
maximum: number;
|
|
140
154
|
};
|
|
155
|
+
/**
|
|
156
|
+
* The colours the screen shows for `pair` once it is drawn moved to `to`,
|
|
157
|
+
* both as the screen shows them, at `depth` on `theme`: what an effect
|
|
158
|
+
* moving a pair's colours leaves its words reading in.
|
|
159
|
+
* [LAW:one-source-of-truth] Read through `movedSpecs`, the one writer a
|
|
160
|
+
* moved slot is written with.
|
|
161
|
+
*/
|
|
162
|
+
static shows(pair: Pair, to: CellColors, depth: ColorDepth, theme: TerminalTheme): CellColors;
|
|
141
163
|
/**
|
|
142
164
|
* The specs that draw a cell's slot colours `to` where the segment drew
|
|
143
|
-
* `from` with `wire`:
|
|
144
|
-
*
|
|
145
|
-
* `SURFACE_BLACK`, the glyph on that ground, as the writer lays any
|
|
146
|
-
* translucent colour (`Style.drawnColors`) — then drawn in the colour nearest it at `depth`
|
|
147
|
-
* that the terminal shows (`ColorSpec.matchOn`). A move the depth cannot
|
|
148
|
-
* show is no move: where the written spec is as near the moved colour as
|
|
149
|
-
* that match, the slot keeps it, so below truecolor a colour moved a little
|
|
150
|
-
* off any colour it can write — a theme slot, the default colour, the cube —
|
|
151
|
-
* is still drawn as that one.
|
|
165
|
+
* `from` with `wire`: each moved slot in the spec `movedSpecs` writes it
|
|
166
|
+
* in, the rest as `wire` wrote them.
|
|
152
167
|
*/
|
|
153
168
|
private respec;
|
|
154
169
|
/** A cell's seed: FNV-1a of the key and the position, scaled into [0, 1). */
|
|
@@ -2,16 +2,19 @@
|
|
|
2
2
|
* Effected — a renderable whose finished cells' colours pass through an
|
|
3
3
|
* effect at a moment `t`.
|
|
4
4
|
*
|
|
5
|
-
* An effect is one pure function: a
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* An effect is one pure function in two stages: a time and what the element
|
|
6
|
+
* draws in that frame in, a function of the frame out; and into that, a
|
|
7
|
+
* cell's colours and where the cell is, the cell's new colours out. Whatever
|
|
8
|
+
* holds for the whole frame is worked out in the first stage, once, and every
|
|
9
|
+
* cell of the frame is handed to the second. Shimmer, pulse, fade and
|
|
10
|
+
* dissolve are all this one shape, differing by what the function does with
|
|
11
|
+
* its inputs [LAW:one-type-per-behavior]; two effects stack by nesting one
|
|
12
|
+
* wrapper in another, which is the same as calling one after the other but
|
|
13
|
+
* for what the outer one measures: the inner one's output.
|
|
11
14
|
*
|
|
12
15
|
* What the element draws (`Drawn`) is measured here, in the render, off the
|
|
13
|
-
* same reading of each segment its cells are drawn from, and handed to
|
|
14
|
-
*
|
|
16
|
+
* same reading of each segment its cells are drawn from, and handed to the
|
|
17
|
+
* effect's first stage, once a frame. It is what an effect keeping words legible is
|
|
15
18
|
* settled against (`onColors`, `./effects.ts`), so no caller derives it a
|
|
16
19
|
* second time from a render of its own.
|
|
17
20
|
*
|
|
@@ -42,7 +45,7 @@
|
|
|
42
45
|
*/
|
|
43
46
|
import { drawnBy, shiftAnchor } from "../core/anchor.js";
|
|
44
47
|
import { cellLen, graphemes } from "../core/cells.js";
|
|
45
|
-
import { ColorDepth, ColorSpec, SURFACE_BLACK
|
|
48
|
+
import { ColorDepth, ColorSpec, SURFACE_BLACK } from "../core/color.js";
|
|
46
49
|
import { fnv1a } from "../core/fnv1a.js";
|
|
47
50
|
import { Measurement } from "../core/measure.js";
|
|
48
51
|
import { Segment } from "../core/segment.js";
|
|
@@ -75,7 +78,7 @@ export class Effected {
|
|
|
75
78
|
// [LAW:one-source-of-truth] The frame is measured before any cell is
|
|
76
79
|
// handed to the effect, off the same looks its cells are drawn from.
|
|
77
80
|
const looked = looks(segments, depth, this.theme);
|
|
78
|
-
const
|
|
81
|
+
const frame = this.effect(this.t, measured(looked, depth, this.theme));
|
|
79
82
|
let row = 0;
|
|
80
83
|
let col = 0;
|
|
81
84
|
for (const { segment, look } of looked) {
|
|
@@ -102,7 +105,7 @@ export class Effected {
|
|
|
102
105
|
else {
|
|
103
106
|
const anchor = base.anchor && shiftAnchor(base.anchor, into);
|
|
104
107
|
// Back from the screen to the style's slots: the same swap undoes itself.
|
|
105
|
-
colors = onScreen(
|
|
108
|
+
colors = onScreen(frame(from, { row, col, seed: this.seed(row, col), terminal, anchor }), pair.reverse);
|
|
106
109
|
col += cellLen(glyph);
|
|
107
110
|
}
|
|
108
111
|
into += cellLen(glyph);
|
|
@@ -133,27 +136,26 @@ export class Effected {
|
|
|
133
136
|
measure(options) {
|
|
134
137
|
return Measurement.get(options, this.renderable);
|
|
135
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* The colours the screen shows for `pair` once it is drawn moved to `to`,
|
|
141
|
+
* both as the screen shows them, at `depth` on `theme`: what an effect
|
|
142
|
+
* moving a pair's colours leaves its words reading in.
|
|
143
|
+
* [LAW:one-source-of-truth] Read through `movedSpecs`, the one writer a
|
|
144
|
+
* moved slot is written with.
|
|
145
|
+
*/
|
|
146
|
+
static shows(pair, to, depth, theme) {
|
|
147
|
+
const from = onScreen(pair, pair.reverse);
|
|
148
|
+
const moved = movedSpecs(from, onScreen(to, pair.reverse), depth, theme);
|
|
149
|
+
return onScreen({ fg: moved.fg?.getTruecolor(theme, true) ?? from.fg, bg: moved.bg?.getTruecolor(theme, false) ?? from.bg }, pair.reverse);
|
|
150
|
+
}
|
|
136
151
|
/**
|
|
137
152
|
* The specs that draw a cell's slot colours `to` where the segment drew
|
|
138
|
-
* `from` with `wire`:
|
|
139
|
-
*
|
|
140
|
-
* `SURFACE_BLACK`, the glyph on that ground, as the writer lays any
|
|
141
|
-
* translucent colour (`Style.drawnColors`) — then drawn in the colour nearest it at `depth`
|
|
142
|
-
* that the terminal shows (`ColorSpec.matchOn`). A move the depth cannot
|
|
143
|
-
* show is no move: where the written spec is as near the moved colour as
|
|
144
|
-
* that match, the slot keeps it, so below truecolor a colour moved a little
|
|
145
|
-
* off any colour it can write — a theme slot, the default colour, the cube —
|
|
146
|
-
* is still drawn as that one.
|
|
153
|
+
* `from` with `wire`: each moved slot in the spec `movedSpecs` writes it
|
|
154
|
+
* in, the rest as `wire` wrote them.
|
|
147
155
|
*/
|
|
148
156
|
respec(wire, from, to, depth) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const glyph = to.fg.compositeOver(ground);
|
|
152
|
-
const drawn = (spec, was, now, foreground) => {
|
|
153
|
-
const match = ColorSpec.matchOn(now, depth, this.theme, foreground);
|
|
154
|
-
return rgbDistance(was, now) <= rgbDistance(match.getTruecolor(this.theme, foreground), now) ? spec : match;
|
|
155
|
-
};
|
|
156
|
-
return { color: drawn(wire.color, from.fg, glyph, true), bgcolor: drawn(wire.bgcolor, from.bg, ground, false) };
|
|
157
|
+
const moved = movedSpecs(from, to, depth, this.theme);
|
|
158
|
+
return { color: moved.fg ?? wire.color, bgcolor: moved.bg ?? wire.bgcolor };
|
|
157
159
|
}
|
|
158
160
|
/** A cell's seed: FNV-1a of the key and the position, scaled into [0, 1). */
|
|
159
161
|
seed(row, col) {
|
|
@@ -169,14 +171,15 @@ function looks(segments, depth, theme) {
|
|
|
169
171
|
const style = segment.style ?? Style.null();
|
|
170
172
|
const wire = style.drawnColors(depth);
|
|
171
173
|
const slots = { fg: (wire.color ?? DEFAULT).getTruecolor(theme, true), bg: (wire.bgcolor ?? DEFAULT).getTruecolor(theme, false) };
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
+
const reverse = style.reverse === true;
|
|
175
|
+
const terminal = onScreen({ fg: wire.color?.isDefault ?? true, bg: wire.bgcolor?.isDefault ?? true }, reverse);
|
|
176
|
+
return { segment, look: { style, wire, slots, pair: { ...onScreen(slots, reverse), terminal, reverse } } };
|
|
174
177
|
});
|
|
175
178
|
}
|
|
176
179
|
/** What `looked` draws its words in: `Drawn`'s measure, taken once per frame. */
|
|
177
180
|
function measured(looked, depth, theme) {
|
|
178
181
|
const words = looked.flatMap(({ segment, look }) => (look !== undefined && segment.text.trim() !== "" && !drawnBy(look.style.anchor, JOIN) ? [look.pair] : []));
|
|
179
|
-
const pairs = [...new Map(words.map((pair) => [`${pair.fg.hex}/${pair.bg.hex}/${pair.terminal.fg}/${pair.terminal.bg}`, pair])).values()];
|
|
182
|
+
const pairs = [...new Map(words.map((pair) => [`${pair.fg.hex}/${pair.bg.hex}/${pair.terminal.fg}/${pair.terminal.bg}/${pair.reverse}`, pair])).values()];
|
|
180
183
|
// [LAW:one-source-of-truth] A slot the terminal fills is no colour of the element's, whatever its hex.
|
|
181
184
|
const own = (slot) => pairs.flatMap((pair) => (pair.terminal[slot] ? [] : [pair[slot].hex]));
|
|
182
185
|
return { pairs, colors: new Set([...own("fg"), ...own("bg")]), fills: new Set(own("bg")), depth, theme };
|
|
@@ -190,10 +193,13 @@ function measured(looked, depth, theme) {
|
|
|
190
193
|
* (`colorsOwnedBy`, `../core/strip.ts`).
|
|
191
194
|
*/
|
|
192
195
|
export function confined(effect, owner) {
|
|
193
|
-
return (
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
196
|
+
return (t, drawn) => {
|
|
197
|
+
const frame = effect(t, drawn);
|
|
198
|
+
return (colors, cell) => {
|
|
199
|
+
const owned = colorsOwnedBy(cell.anchor, owner);
|
|
200
|
+
const moved = frame(colors, cell);
|
|
201
|
+
return { fg: owned.fg ? moved.fg : colors.fg, bg: owned.bg ? moved.bg : colors.bg };
|
|
202
|
+
};
|
|
197
203
|
};
|
|
198
204
|
}
|
|
199
205
|
/**
|
|
@@ -208,8 +214,22 @@ export function drawnOf(segments, depth, theme) {
|
|
|
208
214
|
* A style's colour slots as the screen shows them, or the reverse: `reverse`
|
|
209
215
|
* swaps glyph and ground, and swapping twice is where you started.
|
|
210
216
|
*/
|
|
211
|
-
function onScreen(slots,
|
|
212
|
-
return
|
|
217
|
+
function onScreen(slots, reverse) {
|
|
218
|
+
return reverse ? { fg: slots.bg, bg: slots.fg } : slots;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The specs a cell's slots are written in once their colours move from
|
|
222
|
+
* `from`, what the written specs draw, to `to`: each laid on what is beneath
|
|
223
|
+
* it — the ground on `SURFACE_BLACK`, the glyph on that ground, as the writer
|
|
224
|
+
* lays any translucent colour (`Style.drawnColors`) — then matched at `depth`
|
|
225
|
+
* (`ColorSpec.matchMove`). A slot the depth shows no move in has none, and
|
|
226
|
+
* keeps the spec it was written in.
|
|
227
|
+
*/
|
|
228
|
+
function movedSpecs(from, to, depth, theme) {
|
|
229
|
+
// [LAW:one-source-of-truth] The writer's surface, so one translucent colour draws one way in a frame.
|
|
230
|
+
const ground = to.bg.compositeOver(SURFACE_BLACK);
|
|
231
|
+
const glyph = to.fg.compositeOver(ground);
|
|
232
|
+
return { fg: ColorSpec.matchMove(from.fg, glyph, depth, theme, true), bg: ColorSpec.matchMove(from.bg, ground, depth, theme, false) };
|
|
213
233
|
}
|
|
214
234
|
/**
|
|
215
235
|
* Whether two cells' colours are written alike. A slot with no colour is not
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* long as they are drawn, and fade-in and dissolve-out, which run once and
|
|
4
4
|
* are done. Each is an `Effect` for `Effected` (`./effect.ts`); the defaults
|
|
5
5
|
* in `EFFECT_CURVES` are the ones signed off by eye in the effects-feel demo
|
|
6
|
-
* (`npm run effects-feel`), at 30 fps and at one frame a second
|
|
6
|
+
* (`npm run effects-feel`), at 30 fps and at one frame a second, and
|
|
7
|
+
* `EFFECT_CATALOGUE` makes every one of them at those, by name, one way.
|
|
7
8
|
*
|
|
8
9
|
* Each effect is something a first-time observer already knows from life —
|
|
9
10
|
* a slow glow, sunlight moving on water, the colours of daylight, fireflies, ink
|
|
@@ -43,11 +44,15 @@
|
|
|
43
44
|
*/
|
|
44
45
|
import { ColorRgba } from "../core/color.js";
|
|
45
46
|
import { type Ease } from "../core/easing.js";
|
|
46
|
-
import type
|
|
47
|
+
import { type Drawn, type Effect, type EffectCell } from "./effect.js";
|
|
47
48
|
/** What a loop does to one colour at strength `w` in [0, 1]: at 0, nothing. */
|
|
48
49
|
export type Touch = (color: ColorRgba, w: number) => ColorRgba;
|
|
49
|
-
/**
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* How strongly a loop acts at a moment `t` on each cell, in [0, 1]; `onColors`
|
|
52
|
+
* holds a field outside it to it. Staged as `Effect` is: what holds for the
|
|
53
|
+
* whole moment is worked out once, then every cell is asked.
|
|
54
|
+
*/
|
|
55
|
+
export type Field = (t: number) => (cell: EffectCell) => number;
|
|
51
56
|
/** A looping effect: what it does to a colour, and where and when, how strongly. */
|
|
52
57
|
export interface Loop {
|
|
53
58
|
readonly touch: Touch;
|
|
@@ -55,9 +60,10 @@ export interface Loop {
|
|
|
55
60
|
}
|
|
56
61
|
/**
|
|
57
62
|
* How much of `touch` each of `colors` (hex) can take: the largest share in
|
|
58
|
-
* [0, 1] at which every pair
|
|
63
|
+
* [0, 1] at which every pair `drawn` shows it in, touched at the same
|
|
59
64
|
* strength, still reads at its resting contrast or `LEGIBLE`, whichever is
|
|
60
|
-
* lower
|
|
65
|
+
* lower, in the colours `Effected` writes it in at `drawn`'s depth and theme.
|
|
66
|
+
* A colour in a pair but not in `colors` takes none, and so does one
|
|
61
67
|
* the terminal supplies, whatever its hex, as in `onColors`. One of `colors`
|
|
62
68
|
* no pair writes has nothing to be measured against, and takes it all.
|
|
63
69
|
*
|
|
@@ -68,14 +74,14 @@ export interface Loop {
|
|
|
68
74
|
* the fill room to glow — and a darker colour left as it is always holds,
|
|
69
75
|
* because its lighter partner was settled against exactly that.
|
|
70
76
|
*/
|
|
71
|
-
export declare function shares(pairs:
|
|
77
|
+
export declare function shares({ pairs, depth, theme }: Drawn, colors: ReadonlySet<string>, touch: Touch): Map<string, number>;
|
|
72
78
|
/**
|
|
73
79
|
* A loop as an `Effect` on the element's own colours, by hex, wherever a cell
|
|
74
80
|
* shows them, each touched at its share of the cell's strength: the share
|
|
75
81
|
* `shares` settles for it against the pairs the element draws its words in
|
|
76
|
-
* (`Drawn`), touched as the frame's depth
|
|
77
|
-
* touched colour lands on
|
|
78
|
-
* colour's. `colors` are the ones lit, by default every colour the element
|
|
82
|
+
* (`Drawn`), touched as `Effected` writes them at the frame's depth — below
|
|
83
|
+
* truecolor a touched colour lands on a colour the slot can write, and the
|
|
84
|
+
* contrast it spends is that colour's. `colors` are the ones lit, by default every colour the element
|
|
79
85
|
* sets itself. A powerline strip's fill is the ground of its cells and the
|
|
80
86
|
* ink of the seam glyphs between them, so the arrow moves with the cell it
|
|
81
87
|
* points out of. A colour the terminal supplies (`EffectCell.terminal`) is
|
|
@@ -199,46 +205,46 @@ export declare function shimmer(curve: Curve, span: number, width: number, glow:
|
|
|
199
205
|
*/
|
|
200
206
|
export declare function sparkle(curve: Curve, span: number, glow: ColorRgba, z: number): Loop;
|
|
201
207
|
/**
|
|
202
|
-
* How much of
|
|
203
|
-
* to the terminal's ground. Read per cell, so cells can arrive or leave
|
|
204
|
-
* outside [0, 1] is held to it.
|
|
208
|
+
* How much of each cell shows at `t`: 1 is the cell as drawn, 0 is the cell
|
|
209
|
+
* gone to the terminal's ground. Read per cell, so cells can arrive or leave
|
|
210
|
+
* apart, and staged as `Field` is. One outside [0, 1] is held to it.
|
|
205
211
|
*/
|
|
206
|
-
export type Visibility = (
|
|
212
|
+
export type Visibility = (t: number) => (cell: EffectCell) => number;
|
|
207
213
|
/**
|
|
208
|
-
* A cell mixed toward the
|
|
209
|
-
*
|
|
210
|
-
* of its own — a powerline strip —
|
|
211
|
-
*
|
|
212
|
-
* to the ground. `swing` scales the
|
|
213
|
-
* cell wholly shown is returned as it was.
|
|
214
|
+
* A cell mixed toward the ground of the terminal the frame is drawn on
|
|
215
|
+
* (`Drawn.theme`) by how hidden it is: its fill toward the ground, and its ink
|
|
216
|
+
* toward that fill, so an element with fills of its own — a powerline strip —
|
|
217
|
+
* arrives and leaves whole, fill and words together, never as bare coloured
|
|
218
|
+
* blocks. Invisible is ink and fill equal to the ground. `swing` scales the
|
|
219
|
+
* hiding — 1 reaches fully invisible. A cell wholly shown is returned as it was.
|
|
214
220
|
*/
|
|
215
|
-
export declare function veiled(visibility: Visibility, swing: number
|
|
221
|
+
export declare function veiled(visibility: Visibility, swing: number): Effect;
|
|
216
222
|
/**
|
|
217
|
-
* An effect that runs once: a fade-in or a dissolve-out
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
223
|
+
* An effect that runs once: a fade-in or a dissolve-out over its curve's
|
|
224
|
+
* `seconds` from `t = 0`, its own clock as every effect's is — the caller
|
|
225
|
+
* draws it at the time since it began. `done(t)` is true from the moment
|
|
226
|
+
* every cell has arrived or gone, so the caller knows when to stop drawing an
|
|
227
|
+
* element that is leaving, or to drop the effect from one that has arrived.
|
|
221
228
|
*/
|
|
222
229
|
export interface Transition {
|
|
223
230
|
readonly effect: Effect;
|
|
224
231
|
done(t: number): boolean;
|
|
225
232
|
}
|
|
226
233
|
/**
|
|
227
|
-
* Fade-in
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* `start + seconds`.
|
|
234
|
+
* Fade-in, as ink blooming in water: patches of the element surface first
|
|
235
|
+
* and the rest follows outward from them, each cell rising smoothly over its
|
|
236
|
+
* own part of the duration. Whole at `seconds`.
|
|
231
237
|
*/
|
|
232
|
-
export declare function fadeIn(curve: Curve,
|
|
238
|
+
export declare function fadeIn(curve: Curve, z: number): Transition;
|
|
233
239
|
/**
|
|
234
|
-
* Dissolve-out
|
|
235
|
-
*
|
|
236
|
-
*
|
|
240
|
+
* Dissolve-out, as mist lifting: the element thins in drifting patches, each
|
|
241
|
+
* cell fading smoothly over its own part of the duration, until nothing is
|
|
242
|
+
* left at `seconds`. Mist does not lift
|
|
237
243
|
* in one breath: towards the end, patches of what is left — the later a cell
|
|
238
244
|
* goes, the likelier — rise back part way, as the last of it eddies, before
|
|
239
245
|
* they thin away for good.
|
|
240
246
|
*/
|
|
241
|
-
export declare function dissolveOut(curve: Curve,
|
|
247
|
+
export declare function dissolveOut(curve: Curve, z: number): Transition;
|
|
242
248
|
/**
|
|
243
249
|
* Wheel, as the colour of daylight going round: every hue turning the whole
|
|
244
250
|
* way round once a period — far too slowly to be seen moving, so a glance
|
|
@@ -258,3 +264,32 @@ export declare function dissolveOut(curve: Curve, start: number, z: number, grou
|
|
|
258
264
|
* terminal's.
|
|
259
265
|
*/
|
|
260
266
|
export declare function wheel(curve: Curve, z: number): Effect;
|
|
267
|
+
/**
|
|
268
|
+
* What a catalogue effect is made for: the element it is drawn on, as wide as
|
|
269
|
+
* `span` columns — how far a shimmer crosses, how many fireflies keep to it —
|
|
270
|
+
* and at `z` in the noise, so two elements under one effect do not move in
|
|
271
|
+
* lockstep.
|
|
272
|
+
*/
|
|
273
|
+
export interface EffectTarget {
|
|
274
|
+
readonly span: number;
|
|
275
|
+
readonly z: number;
|
|
276
|
+
}
|
|
277
|
+
/** The names of the effects that loop. */
|
|
278
|
+
export type LoopName = "shimmer" | "pulse" | "sparkle" | "wheel";
|
|
279
|
+
/** The names of the effects that run once. */
|
|
280
|
+
export type TransitionName = "fade" | "dissolve";
|
|
281
|
+
/**
|
|
282
|
+
* Every effect in the catalogue by its name in `EFFECT_CURVES`, each made the
|
|
283
|
+
* one way: for an element, at its signed-off curve unless handed another,
|
|
284
|
+
* with the light and the width it was signed off with. A loop comes lit on
|
|
285
|
+
* the element's own colours (`onColors`); a transition comes as itself, to
|
|
286
|
+
* be told when it is done. [LAW:types-are-the-program] Keyed by the two
|
|
287
|
+
* name types, which `EFFECT_CURVES` is held to as well: a curve signed off
|
|
288
|
+
* does not compile until it is named a loop or a transition, and then not
|
|
289
|
+
* until it has an entry here making one. A picker walking a section's names
|
|
290
|
+
* offers an effect added here with no change of its own.
|
|
291
|
+
*/
|
|
292
|
+
export declare const EFFECT_CATALOGUE: {
|
|
293
|
+
readonly loops: Readonly<Record<LoopName, (element: EffectTarget, curve?: Curve) => Effect>>;
|
|
294
|
+
readonly transitions: Readonly<Record<TransitionName, (element: EffectTarget, curve?: Curve) => Transition>>;
|
|
295
|
+
};
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* long as they are drawn, and fade-in and dissolve-out, which run once and
|
|
4
4
|
* are done. Each is an `Effect` for `Effected` (`./effect.ts`); the defaults
|
|
5
5
|
* in `EFFECT_CURVES` are the ones signed off by eye in the effects-feel demo
|
|
6
|
-
* (`npm run effects-feel`), at 30 fps and at one frame a second
|
|
6
|
+
* (`npm run effects-feel`), at 30 fps and at one frame a second, and
|
|
7
|
+
* `EFFECT_CATALOGUE` makes every one of them at those, by name, one way.
|
|
7
8
|
*
|
|
8
9
|
* Each effect is something a first-time observer already knows from life —
|
|
9
10
|
* a slow glow, sunlight moving on water, the colours of daylight, fireflies, ink
|
|
@@ -41,11 +42,12 @@
|
|
|
41
42
|
* cards runs this file's own declarations as the program a visitor edits
|
|
42
43
|
* (`examples/effects-playground/programs.ts`), on the published library.
|
|
43
44
|
*/
|
|
44
|
-
import {
|
|
45
|
+
import { ColorDepth, ColorRgba, contrastRatio } from "../core/color.js";
|
|
45
46
|
import { EASES, Phase } from "../core/easing.js";
|
|
46
47
|
import { fbm, hash, noise, smoothstep } from "../core/noise.js";
|
|
47
48
|
import { Memo } from "../core/memo.js";
|
|
48
49
|
import { IDENTITY, Oklch } from "../core/oklch.js";
|
|
50
|
+
import { Effected } from "./effect.js";
|
|
49
51
|
/** The contrast no touch takes a cell below, unless it rested below it: WCAG AA for body text. */
|
|
50
52
|
const LEGIBLE = 4.5;
|
|
51
53
|
/**
|
|
@@ -59,11 +61,13 @@ const LEVELS = 48;
|
|
|
59
61
|
const STRENGTHS = Array.from({ length: LEVELS }, (_, i) => (i + 1) / LEVELS);
|
|
60
62
|
/** What 8-bit rounding of an OKLCH round trip can cost a contrast ratio. */
|
|
61
63
|
const ROUNDING = 0.01;
|
|
64
|
+
const SIDES = ["fg", "bg"];
|
|
62
65
|
/**
|
|
63
66
|
* How much of `touch` each of `colors` (hex) can take: the largest share in
|
|
64
|
-
* [0, 1] at which every pair
|
|
67
|
+
* [0, 1] at which every pair `drawn` shows it in, touched at the same
|
|
65
68
|
* strength, still reads at its resting contrast or `LEGIBLE`, whichever is
|
|
66
|
-
* lower
|
|
69
|
+
* lower, in the colours `Effected` writes it in at `drawn`'s depth and theme.
|
|
70
|
+
* A colour in a pair but not in `colors` takes none, and so does one
|
|
67
71
|
* the terminal supplies, whatever its hex, as in `onColors`. One of `colors`
|
|
68
72
|
* no pair writes has nothing to be measured against, and takes it all.
|
|
69
73
|
*
|
|
@@ -74,28 +78,38 @@ const ROUNDING = 0.01;
|
|
|
74
78
|
* the fill room to glow — and a darker colour left as it is always holds,
|
|
75
79
|
* because its lighter partner was settled against exactly that.
|
|
76
80
|
*/
|
|
77
|
-
export function shares(pairs, colors, touch) {
|
|
81
|
+
export function shares({ pairs, depth, theme }, colors, touch) {
|
|
78
82
|
// [LAW:one-source-of-truth] A slot shows a subject colour, by hex, only
|
|
79
83
|
// where the element writes it: the reading `onColors` lights by.
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
const own = (pair, side) => (pair.terminal[side] ? undefined : pair[side].hex);
|
|
85
|
+
const rgba = new Map(pairs.flatMap((pair) => SIDES.flatMap((side) => {
|
|
86
|
+
const hex = own(pair, side);
|
|
87
|
+
return hex === undefined ? [] : [[hex, pair[side]]];
|
|
88
|
+
})));
|
|
89
|
+
// Below truecolor every colour written is one the depth holds exactly, so only truecolor's rounding has a cost to forgive.
|
|
90
|
+
const slack = depth === ColorDepth.TRUECOLOR ? ROUNDING : 0;
|
|
85
91
|
// A colour no pair writes keeps no word legible, so nothing bounds its share:
|
|
86
92
|
// a frame drawing a lit colour only under blanks and seams, or not at all,
|
|
87
93
|
// is a frame like any other.
|
|
88
94
|
const settled = new Map([...colors].filter((hex) => !rgba.has(hex)).map((hex) => [hex, 1]));
|
|
89
95
|
const lightness = (hex) => Oklch.fromRgba(rgba.get(hex)).l;
|
|
90
96
|
for (const hex of [...colors].filter((hex) => rgba.has(hex)).sort((a, b) => lightness(b) - lightness(a))) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
// Each pair showing the colour, with the side it shows it on and the share its partner takes.
|
|
98
|
+
const facing = pairs.flatMap((pair) => {
|
|
99
|
+
const mine = SIDES.find((side) => own(pair, side) === hex);
|
|
100
|
+
if (mine === undefined)
|
|
101
|
+
return [];
|
|
102
|
+
const partner = own(pair, mine === "fg" ? "bg" : "fg");
|
|
103
|
+
return [[pair, mine, partner === undefined ? 0 : (settled.get(partner) ?? 0)]];
|
|
94
104
|
});
|
|
95
|
-
|
|
96
|
-
const holds = (share) =>
|
|
97
|
-
const floor = Math.min(contrastRatio(
|
|
98
|
-
|
|
105
|
+
// [LAW:one-source-of-truth] A pair reads in the colours `Effected` writes it in, touched at each side's share.
|
|
106
|
+
const holds = (share) => facing.every(([pair, mine, theirs]) => {
|
|
107
|
+
const floor = Math.min(contrastRatio(pair.fg, pair.bg), LEGIBLE) - slack;
|
|
108
|
+
const at = (side, w) => touchedAt(touch, pair[side], (side === mine ? share : theirs) * w);
|
|
109
|
+
return STRENGTHS.every((w) => {
|
|
110
|
+
const shown = Effected.shows(pair, { fg: at("fg", w), bg: at("bg", w) }, depth, theme);
|
|
111
|
+
return contrastRatio(shown.fg, shown.bg) >= floor;
|
|
112
|
+
});
|
|
99
113
|
});
|
|
100
114
|
let [lo, hi] = holds(1) ? [1, 1] : [0, 1];
|
|
101
115
|
for (let i = 0; i < 20 && hi - lo > 1e-3; i++) {
|
|
@@ -110,9 +124,9 @@ export function shares(pairs, colors, touch) {
|
|
|
110
124
|
* A loop as an `Effect` on the element's own colours, by hex, wherever a cell
|
|
111
125
|
* shows them, each touched at its share of the cell's strength: the share
|
|
112
126
|
* `shares` settles for it against the pairs the element draws its words in
|
|
113
|
-
* (`Drawn`), touched as the frame's depth
|
|
114
|
-
* touched colour lands on
|
|
115
|
-
* colour's. `colors` are the ones lit, by default every colour the element
|
|
127
|
+
* (`Drawn`), touched as `Effected` writes them at the frame's depth — below
|
|
128
|
+
* truecolor a touched colour lands on a colour the slot can write, and the
|
|
129
|
+
* contrast it spends is that colour's. `colors` are the ones lit, by default every colour the element
|
|
116
130
|
* sets itself. A powerline strip's fill is the ground of its cells and the
|
|
117
131
|
* ink of the seam glyphs between them, so the arrow moves with the cell it
|
|
118
132
|
* points out of. A colour the terminal supplies (`EffectCell.terminal`) is
|
|
@@ -126,11 +140,10 @@ export function onColors(loop, colors) {
|
|
|
126
140
|
const settled = new WeakMap();
|
|
127
141
|
const settle = (drawn) => {
|
|
128
142
|
const lit = colors ?? drawn.colors;
|
|
129
|
-
const key = [drawn.depth, ...drawn.pairs.map(({ fg, bg, terminal }) => `${fg.hex}/${bg.hex}/${terminal.fg}/${terminal.bg}`), "|", ...lit].join(" ");
|
|
143
|
+
const key = [drawn.depth, ...drawn.pairs.map(({ fg, bg, terminal, reverse }) => `${fg.hex}/${bg.hex}/${terminal.fg}/${terminal.bg}/${reverse}`), "|", ...lit].join(" ");
|
|
130
144
|
const memo = settled.get(drawn.theme) ?? new Memo();
|
|
131
145
|
settled.set(drawn.theme, memo);
|
|
132
|
-
|
|
133
|
-
return memo.get(key, () => shares(drawn.pairs, lit, asWritten));
|
|
146
|
+
return memo.get(key, () => shares(drawn, lit, loop.touch));
|
|
134
147
|
};
|
|
135
148
|
return atShares(loop, settle);
|
|
136
149
|
}
|
|
@@ -140,14 +153,14 @@ export function onColors(loop, colors) {
|
|
|
140
153
|
* says; a colour it gives none, and one the terminal supplies, stays as it is.
|
|
141
154
|
*/
|
|
142
155
|
export function atShares(loop, share) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
156
|
+
return (t, drawn) => {
|
|
157
|
+
const shared = share(drawn);
|
|
158
|
+
const field = loop.field(t);
|
|
159
|
+
return ({ fg, bg }, cell) => {
|
|
160
|
+
const w = Math.round(clamp01(field(cell)) * LEVELS) / LEVELS;
|
|
161
|
+
const moved = (color, terminal) => touchedAt(loop.touch, color, (terminal ? 0 : (shared.get(color.hex) ?? 0)) * w);
|
|
162
|
+
return { fg: moved(fg, cell.terminal.fg), bg: moved(bg, cell.terminal.bg) };
|
|
163
|
+
};
|
|
151
164
|
};
|
|
152
165
|
}
|
|
153
166
|
/**
|
|
@@ -276,14 +289,15 @@ export function pulse(curve, glow, z) {
|
|
|
276
289
|
const WANDER = 0.1;
|
|
277
290
|
const wander = (t) => WANDER * noise(t / (3 * curve.seconds), 0.5, 0.5 + z);
|
|
278
291
|
const start = wander(0);
|
|
279
|
-
const field = (
|
|
292
|
+
const field = (t) => {
|
|
280
293
|
const beats = (rate * t) / curve.seconds + wander(t) - start;
|
|
281
294
|
const n = Math.floor(beats);
|
|
282
295
|
// Each beat's own depth, 70% to the whole of the swing; it changes where
|
|
283
296
|
// the beat is at rest, so nothing jumps.
|
|
284
297
|
const depth = 0.7 + 0.3 * smoothstep(-0.5, 0.5, noise(n * 0.37, 2.1, z));
|
|
285
298
|
const beat = 0.5 - 0.5 * Math.cos(2 * Math.PI * (beats - n));
|
|
286
|
-
|
|
299
|
+
const level = curve.swing * curve.ease(depth * beat);
|
|
300
|
+
return () => level;
|
|
287
301
|
};
|
|
288
302
|
return { touch: light(glow), field };
|
|
289
303
|
}
|
|
@@ -343,35 +357,31 @@ export function shimmer(curve, span, width, glow, z) {
|
|
|
343
357
|
const centre = q * across - width + 0.06 * across * Math.sin(Math.PI * q) * noise(q, 2.7, j + z);
|
|
344
358
|
return { strength, centre };
|
|
345
359
|
};
|
|
346
|
-
// The passes on the row at
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const last = Math.floor(t / SLOT);
|
|
354
|
-
const passes = [];
|
|
355
|
-
for (let j = Math.max(0, Math.ceil(last - UP)); j <= last; j++)
|
|
356
|
-
passes.push(pass(j, t));
|
|
357
|
-
passing = { t, passes };
|
|
358
|
-
}
|
|
359
|
-
let lit = 0;
|
|
360
|
-
for (const { strength, centre } of passing.passes)
|
|
361
|
-
lit = Math.max(lit, strength * bump((col - centre) / width));
|
|
362
|
-
return lit;
|
|
360
|
+
// The passes on the row at `t`; the first slot is 0, so before it there are none.
|
|
361
|
+
const passing = (t) => {
|
|
362
|
+
const last = Math.floor(t / SLOT);
|
|
363
|
+
const passes = [];
|
|
364
|
+
for (let j = Math.max(0, Math.ceil(last - UP)); j <= last; j++)
|
|
365
|
+
passes.push(pass(j, t));
|
|
366
|
+
return passes;
|
|
363
367
|
};
|
|
364
368
|
// A ripple's crest: a Gaussian ridge, 1 where the field crosses zero and in
|
|
365
369
|
// (0, 1] for any noise, smooth throughout, so a crest sliding through a cell
|
|
366
370
|
// lights it smoothly.
|
|
367
371
|
const ripple = (a, b, c) => Math.exp(-((noise(a, b, c) / 0.8) ** 2));
|
|
368
|
-
const field = (
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
372
|
+
const field = (t) => {
|
|
373
|
+
const passes = passing(t);
|
|
374
|
+
return (cell) => {
|
|
375
|
+
// Where two passes overlap the brighter wins, so light never doubles up.
|
|
376
|
+
let lit = 0;
|
|
377
|
+
for (const { strength, centre } of passes)
|
|
378
|
+
lit = Math.max(lit, strength * bump((cell.col - centre) / width));
|
|
379
|
+
const row = cell.row + z;
|
|
380
|
+
const current = 0.8 * noise(cell.col * 0.05, row * 0.3 + 1.3, t * 0.02);
|
|
381
|
+
const caustic = (ripple(cell.col * 0.21 + current, row * 0.9, t * 0.022) * ripple(cell.col * 0.33 + 9.1 - current, row * 0.7, t * 0.018 + 4.2)) **
|
|
382
|
+
2;
|
|
383
|
+
return curve.swing * curve.ease(lit * (0.1 + 0.6 * caustic));
|
|
384
|
+
};
|
|
375
385
|
};
|
|
376
386
|
return { touch: light(glow), field };
|
|
377
387
|
}
|
|
@@ -403,67 +413,64 @@ export function sparkle(curve, span, glow, z) {
|
|
|
403
413
|
// Out of step with every other firefly from the first moment.
|
|
404
414
|
offset: hash(i, 0.7 + z) * TURN,
|
|
405
415
|
}));
|
|
406
|
-
// Where every firefly is and how bright
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
};
|
|
442
|
-
const field = (cell, t) => {
|
|
443
|
-
// Where two halos overlap the brighter wins, so light never doubles up.
|
|
444
|
-
let shine = 0;
|
|
445
|
-
for (const fly of fliesAt(t))
|
|
446
|
-
shine = Math.max(shine, fly.glow * bump(Math.hypot(cell.col - fly.x, 2 * (cell.row - fly.y)) / HALO));
|
|
447
|
-
return curve.swing * curve.ease(shine);
|
|
416
|
+
// Where every firefly is at `t`, and how bright.
|
|
417
|
+
const fliesAt = (t) => homes.map((fly) => {
|
|
418
|
+
// Flash `n` is one event: whether it comes, how bright, when in
|
|
419
|
+
// its turn and where are its own. Between flashes the firefly
|
|
420
|
+
// is dark, so where it moves to is never seen moving.
|
|
421
|
+
const u = t + fly.offset;
|
|
422
|
+
const n = Math.floor(u / TURN);
|
|
423
|
+
// A dark turn is a flash with no light: it keeps its length, so
|
|
424
|
+
// every flash is timed alike.
|
|
425
|
+
const lit = hash(n, fly.z + 1) < 0.15 ? 0 : 1;
|
|
426
|
+
const k = 0.5 + 0.5 * hash(n, fly.z + 2);
|
|
427
|
+
const at = hash(n, fly.z + 3) * (1 - lasts(FLASH, k));
|
|
428
|
+
const s = u / TURN - n - at;
|
|
429
|
+
// While it glows it drifts a few cells one way or the other, as
|
|
430
|
+
// fast as it is bright: quickest at its height, slowing as it
|
|
431
|
+
// fades — the lit stroke a firefly draws on the dusk. A brighter
|
|
432
|
+
// flash lasts longer and draws a longer stroke at the same pace.
|
|
433
|
+
return {
|
|
434
|
+
x: fly.home +
|
|
435
|
+
HOP * (2 * hash(n, fly.z + 4) - 1) +
|
|
436
|
+
GLIDE * k * (2 * hash(n, fly.z + 5) - 1) * given(FLASH, s, k) +
|
|
437
|
+
1.5 * noise(fly.z, 0.1, t * 0.035),
|
|
438
|
+
y: 0.6 * noise(fly.z, 2.2, t * 0.035),
|
|
439
|
+
glow: lit * swell(FLASH, s, k),
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
const field = (t) => {
|
|
443
|
+
const flies = fliesAt(t);
|
|
444
|
+
return (cell) => {
|
|
445
|
+
// Where two halos overlap the brighter wins, so light never doubles up.
|
|
446
|
+
let shine = 0;
|
|
447
|
+
for (const fly of flies)
|
|
448
|
+
shine = Math.max(shine, fly.glow * bump(Math.hypot(cell.col - fly.x, 2 * (cell.row - fly.y)) / HALO));
|
|
449
|
+
return curve.swing * curve.ease(shine);
|
|
450
|
+
};
|
|
448
451
|
};
|
|
449
452
|
return { touch: light(glow), field };
|
|
450
453
|
}
|
|
451
454
|
/**
|
|
452
|
-
* A cell mixed toward the
|
|
453
|
-
*
|
|
454
|
-
* of its own — a powerline strip —
|
|
455
|
-
*
|
|
456
|
-
* to the ground. `swing` scales the
|
|
457
|
-
* cell wholly shown is returned as it was.
|
|
455
|
+
* A cell mixed toward the ground of the terminal the frame is drawn on
|
|
456
|
+
* (`Drawn.theme`) by how hidden it is: its fill toward the ground, and its ink
|
|
457
|
+
* toward that fill, so an element with fills of its own — a powerline strip —
|
|
458
|
+
* arrives and leaves whole, fill and words together, never as bare coloured
|
|
459
|
+
* blocks. Invisible is ink and fill equal to the ground. `swing` scales the
|
|
460
|
+
* hiding — 1 reaches fully invisible. A cell wholly shown is returned as it was.
|
|
458
461
|
*/
|
|
459
|
-
export function veiled(visibility, swing
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
462
|
+
export function veiled(visibility, swing) {
|
|
463
|
+
return (t, { theme }) => {
|
|
464
|
+
const shows = visibility(t);
|
|
465
|
+
// [LAW:one-source-of-truth] The ground is the terminal `Effected` draws on, never a second one handed here.
|
|
466
|
+
const ground = Oklch.fromRgba(theme.backgroundColor);
|
|
467
|
+
return (colors, cell) => {
|
|
468
|
+
const hidden = swing * (1 - clamp01(shows(cell)));
|
|
469
|
+
if (hidden === 0)
|
|
470
|
+
return colors;
|
|
471
|
+
const bg = blend(Oklch.fromRgba(colors.bg), ground, hidden);
|
|
472
|
+
return { fg: blend(Oklch.fromRgba(colors.fg), bg, hidden).toRgba(), bg: bg.toRgba() };
|
|
473
|
+
};
|
|
467
474
|
};
|
|
468
475
|
}
|
|
469
476
|
/**
|
|
@@ -484,9 +491,8 @@ function order(z) {
|
|
|
484
491
|
return place;
|
|
485
492
|
};
|
|
486
493
|
}
|
|
487
|
-
function transition(curve,
|
|
488
|
-
|
|
489
|
-
return { effect, done: (t) => t >= end };
|
|
494
|
+
function transition(curve, effect) {
|
|
495
|
+
return { effect, done: (t) => t >= curve.seconds };
|
|
490
496
|
}
|
|
491
497
|
/**
|
|
492
498
|
* Of a transition's `seconds`, how long each cell's own change takes. Under
|
|
@@ -494,26 +500,28 @@ function transition(curve, start, effect) {
|
|
|
494
500
|
*/
|
|
495
501
|
const OWN = 0.45;
|
|
496
502
|
/**
|
|
497
|
-
* Each cell's progress through its own change in a transition
|
|
498
|
-
*
|
|
499
|
-
*
|
|
503
|
+
* Each cell's progress through its own change in a transition, in [0, 1]: a
|
|
504
|
+
* cell `place` along the order starts that share of the way through the time
|
|
505
|
+
* the others leave it, and takes `OWN` of `seconds`.
|
|
500
506
|
*/
|
|
501
|
-
function ownChange(curve,
|
|
507
|
+
function ownChange(curve, place) {
|
|
502
508
|
const own = curve.seconds * OWN;
|
|
503
509
|
// One phase, shifted per cell, so no cell builds its own each frame.
|
|
504
|
-
const change = Phase.once(
|
|
505
|
-
return (
|
|
510
|
+
const change = Phase.once(0, own);
|
|
511
|
+
return (t) => (cell) => change(t - place(cell) * (curve.seconds - own));
|
|
506
512
|
}
|
|
507
513
|
/**
|
|
508
|
-
* Fade-in
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
* `start + seconds`.
|
|
514
|
+
* Fade-in, as ink blooming in water: patches of the element surface first
|
|
515
|
+
* and the rest follows outward from them, each cell rising smoothly over its
|
|
516
|
+
* own part of the duration. Whole at `seconds`.
|
|
512
517
|
*/
|
|
513
|
-
export function fadeIn(curve,
|
|
518
|
+
export function fadeIn(curve, z) {
|
|
514
519
|
requireCurve(curve);
|
|
515
|
-
const risen = ownChange(curve,
|
|
516
|
-
return transition(curve,
|
|
520
|
+
const risen = ownChange(curve, order(1.7 + z));
|
|
521
|
+
return transition(curve, veiled((t) => {
|
|
522
|
+
const at = risen(t);
|
|
523
|
+
return (cell) => curve.ease(at(cell));
|
|
524
|
+
}, curve.swing));
|
|
517
525
|
}
|
|
518
526
|
/**
|
|
519
527
|
* The rebound of a dissolve, every constant that makes it feel as it does —
|
|
@@ -526,25 +534,28 @@ export function fadeIn(curve, start, z, ground) {
|
|
|
526
534
|
*/
|
|
527
535
|
const DISSOLVE_SHAPE = { depth: 0.4, from: 0.5, to: 0.95, maskFrom: 0.35, maskTo: 0.75, late: 0.6 };
|
|
528
536
|
/**
|
|
529
|
-
* Dissolve-out
|
|
530
|
-
*
|
|
531
|
-
*
|
|
537
|
+
* Dissolve-out, as mist lifting: the element thins in drifting patches, each
|
|
538
|
+
* cell fading smoothly over its own part of the duration, until nothing is
|
|
539
|
+
* left at `seconds`. Mist does not lift
|
|
532
540
|
* in one breath: towards the end, patches of what is left — the later a cell
|
|
533
541
|
* goes, the likelier — rise back part way, as the last of it eddies, before
|
|
534
542
|
* they thin away for good.
|
|
535
543
|
*/
|
|
536
|
-
export function dissolveOut(curve,
|
|
544
|
+
export function dissolveOut(curve, z) {
|
|
537
545
|
requireCurve(curve);
|
|
538
546
|
const place = order(4.2 + z);
|
|
539
547
|
const eddy = order(9.3 + z);
|
|
540
|
-
const faded = ownChange(curve,
|
|
548
|
+
const faded = ownChange(curve, place);
|
|
541
549
|
const { depth, from, to, maskFrom, maskTo, late } = DISSOLVE_SHAPE;
|
|
542
|
-
return transition(curve,
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
550
|
+
return transition(curve, veiled((t) => {
|
|
551
|
+
const at = faded(t);
|
|
552
|
+
return (cell) => {
|
|
553
|
+
const gone = curve.ease(at(cell));
|
|
554
|
+
const q = clamp01((gone - from) / (to - from));
|
|
555
|
+
const back = depth * smoothstep(maskFrom, maskTo, eddy(cell)) * (1 - late + late * place(cell)) * Math.sin(Math.PI * q) ** 2;
|
|
556
|
+
return 1 - gone + back;
|
|
557
|
+
};
|
|
558
|
+
}, curve.swing));
|
|
548
559
|
}
|
|
549
560
|
/**
|
|
550
561
|
* The periods, in seconds, of the two noises a segment's stray from the
|
|
@@ -586,7 +597,7 @@ export function wheel(curve, z) {
|
|
|
586
597
|
const s = seed(hex);
|
|
587
598
|
return (360 * t) / P + curve.swing * STRAY * ((stray(s, t) - stray(s, 0)) / 1.5);
|
|
588
599
|
};
|
|
589
|
-
return (
|
|
600
|
+
return (t, { colors, fills }) => (shown, cell) => {
|
|
590
601
|
// Whether a slot shows one of the element's colours: never the terminal's, whatever its hex.
|
|
591
602
|
const own = { fg: !cell.terminal.fg && colors.has(shown.fg.hex), bg: !cell.terminal.bg && colors.has(shown.bg.hex) };
|
|
592
603
|
// An own colour turns with its segment: itself if a fill or beside no own colour, else the one beside it.
|
|
@@ -594,3 +605,26 @@ export function wheel(curve, z) {
|
|
|
594
605
|
return { fg: turn(shown.fg, own.fg, shown.bg, own.bg), bg: turn(shown.bg, own.bg, shown.fg, own.fg) };
|
|
595
606
|
};
|
|
596
607
|
}
|
|
608
|
+
/**
|
|
609
|
+
* Every effect in the catalogue by its name in `EFFECT_CURVES`, each made the
|
|
610
|
+
* one way: for an element, at its signed-off curve unless handed another,
|
|
611
|
+
* with the light and the width it was signed off with. A loop comes lit on
|
|
612
|
+
* the element's own colours (`onColors`); a transition comes as itself, to
|
|
613
|
+
* be told when it is done. [LAW:types-are-the-program] Keyed by the two
|
|
614
|
+
* name types, which `EFFECT_CURVES` is held to as well: a curve signed off
|
|
615
|
+
* does not compile until it is named a loop or a transition, and then not
|
|
616
|
+
* until it has an entry here making one. A picker walking a section's names
|
|
617
|
+
* offers an effect added here with no change of its own.
|
|
618
|
+
*/
|
|
619
|
+
export const EFFECT_CATALOGUE = {
|
|
620
|
+
loops: {
|
|
621
|
+
shimmer: (element, curve = EFFECT_CURVES.shimmer) => onColors(shimmer(curve, element.span, SHIMMER_WIDTH, EFFECT_LIGHTS.sun, element.z)),
|
|
622
|
+
pulse: (element, curve = EFFECT_CURVES.pulse) => onColors(pulse(curve, EFFECT_LIGHTS.sun, element.z)),
|
|
623
|
+
sparkle: (element, curve = EFFECT_CURVES.sparkle) => onColors(sparkle(curve, element.span, EFFECT_LIGHTS.firefly, element.z)),
|
|
624
|
+
wheel: (element, curve = EFFECT_CURVES.wheel) => wheel(curve, element.z),
|
|
625
|
+
},
|
|
626
|
+
transitions: {
|
|
627
|
+
fade: (element, curve = EFFECT_CURVES.fade) => fadeIn(curve, element.z),
|
|
628
|
+
dissolve: (element, curve = EFFECT_CURVES.dissolve) => dissolveOut(curve, element.z),
|
|
629
|
+
},
|
|
630
|
+
};
|