@stanko/ctrls 0.1.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/README.md ADDED
@@ -0,0 +1,359 @@
1
+ # Ctrls
2
+
3
+ ## Made for algorithmic art
4
+
5
+ I made Ctrls for my [algorithmic art projects](https://muffinman.io/art/). By default, the state is saved in the URL, which lets you navigate history and easily share links. Besides standard components like checkboxes and ranges, it also includes two that are especially useful for generative projects: RNG seed and easing. These not only let you adjust parameters but also provide functions (`rng` and `easing`) that you can call directly.
6
+
7
+ You can play with the live example above or check the older version on my [Space Invaders generator](https://muffinman.io/invaders/).
8
+
9
+ ## Features
10
+
11
+ - Minimal API
12
+ - Six components (RNG seed, easing, boolean, radio, range and dual range)
13
+ - Fully typed, including the dynamic typings for properties it controls
14
+ - Light and dark themes included
15
+ - Easy to theme through CSS variables
16
+
17
+ ## Quick start
18
+
19
+ Install the library:
20
+
21
+ ```bash
22
+ npm install @stanko/ctrls
23
+ ````
24
+
25
+ Define your controls as an array and instantiate a `Ctrls` class:
26
+
27
+ ```ts
28
+ import { Ctrls } from "@stanko/ctrls";
29
+ import type { TypedControlConfig } from "@stanko/ctrls";
30
+
31
+ // Import CSS
32
+ import "@stanko/ctrls/dist/ctrls.css";
33
+
34
+ // Define configuration for controls
35
+ const config = [
36
+ {
37
+ type: "boolean",
38
+ name: "animate",
39
+ defaultValue: true,
40
+ isRandomizationDisabled: true,
41
+ },
42
+ {
43
+ type: "seed",
44
+ name: "opacitySeed",
45
+ },
46
+ {
47
+ type: "range",
48
+ name: "hue",
49
+ defaultValue: 220,
50
+ min: 0,
51
+ max: 360,
52
+ step: 1,
53
+ },
54
+ // in the demo above there are three more components:
55
+ // - speed, shape and size
56
+
57
+ // Casting the config to enable editor's code completion
58
+ ] as const satisfies readonly TypedControlConfig[];
59
+
60
+ // Create the instance of Ctrls
61
+ export const options = new Ctrls(config, {
62
+ showRandomizeButton: true,
63
+ });
64
+
65
+ export type Options = ReturnValue<options.getValues()>;
66
+ ```
67
+
68
+ Add your handlers. Ctrls provides `onChange` and `onInput` methods.
69
+
70
+ To get the current values, use `.getValues()`.
71
+
72
+ ```ts
73
+ options.onChange = (updatedValues: Partial<Options>) => {
74
+ // Example: re-render your drawing
75
+ render(options.getValues()); // Get all values and pass it to the render method
76
+ };
77
+
78
+ options.onInput = (updatedValues: Partial<Options>) => {
79
+ // Example: update a CSS variable
80
+ if (updatedValues.hue) {
81
+ document.body.style.setProperty("--hue", updatedValues.hue);
82
+ }
83
+ };
84
+ ```
85
+
86
+ ## API
87
+
88
+ To instantiate:
89
+
90
+ ```ts
91
+ type ControlsOptions = {
92
+ showRandomizeButton?: boolean;
93
+ storage?: "hash" | null;
94
+ theme?: "system" | "light" | "dark";
95
+ };
96
+ ```
97
+
98
+ Public API:
99
+
100
+ * `element`
101
+ * `onChange`
102
+ * `onInput`
103
+ * `getValues`
104
+ * `randomize`
105
+
106
+ But technically all of the methods are public. I've done this on purpose to let you experiment.
107
+
108
+ ## Controls
109
+
110
+ All controls share the following properties:
111
+
112
+ ```ts
113
+ {
114
+ // --- Mandatory --- //
115
+ type: CtrlType; // "seed" | "easing" | "boolean" | "range" | "dual-range" | "radio"
116
+ name: string;
117
+
118
+ // --- Optional --- //
119
+ // If passed it will be used instead of the name
120
+ label?: string;
121
+ // Depends on the control type, if it is not passed it will be randomly generated
122
+ defaultValue?: T;
123
+ // Should the value be randomized when randomize button is clicked
124
+ isRandomizationDisabled?: boolean; // default: false
125
+ }
126
+ ```
127
+
128
+ Some controls will have a few more properties explained below.
129
+
130
+ ### Boolean
131
+
132
+ A checkbox control for boolean parameter.
133
+
134
+ <div class="example">
135
+
136
+ ```json
137
+ {
138
+ "type": "boolean",
139
+ "name": "debug"
140
+ }
141
+ ```
142
+
143
+ </div>
144
+
145
+ ### Seed
146
+
147
+ A text input which generates a random text seed and creates a random number generator using the seed.
148
+
149
+ <div class="example">
150
+
151
+ ```json
152
+ {
153
+ "type": "seed",
154
+ "name": "mainSeed"
155
+ }
156
+ ```
157
+
158
+ </div>
159
+
160
+ A seeded RNG will be created and it will be included in the values. For the example above the return value of `.getValues()` would look like this:
161
+
162
+ ```ts
163
+ {
164
+ mainSeed: "a-random-seed-value",
165
+ mainSeedRng: () => number;
166
+ }
167
+ ```
168
+
169
+ ### Easing
170
+
171
+ Cubic-bezier easing control which also creates easing function you can use directly. It includes five presets, which can be changed through the `presets` property. To disable presets pass an empty array.
172
+
173
+ ```ts
174
+ {
175
+ // Optional
176
+ presets?: Record<string, [number, number, number, number]>; // default: 1
177
+ }
178
+ ```
179
+
180
+ Example:
181
+
182
+ <div class="example">
183
+
184
+ ```json
185
+ {
186
+ "type": "easing",
187
+ "name": "distribution",
188
+ "defaultValue": [0.8, 0.1, 0.2, 0.9],
189
+ "presets": {
190
+ "ease_in": [0.42, 0, 1, 1],
191
+ "ease_out": [0, 0, 0.58, 1],
192
+ "ease_in_out": [0.42, 0, 0.58, 1]
193
+ }
194
+ }
195
+ ```
196
+
197
+ </div>
198
+
199
+ Please note that the string `ease_` will be removed from the preset labels.
200
+
201
+ An easing function will be created and it will be included in the values. For the example above the return value of `.getValues()` would look like this:
202
+
203
+ ```ts
204
+ {
205
+ distribution: [0.8, 0.1, 0.2, 0.9],
206
+ distributionEasing: (t: number) => number;
207
+ }
208
+ ```
209
+
210
+ ### Range
211
+
212
+ A range slider that controls a number parameter. Accepts minimum, maximum and step values. It includes a few additional properties:
213
+
214
+ ```ts
215
+ {
216
+ // Mandatory
217
+ min: number;
218
+ max: number;
219
+
220
+ // Optional
221
+ step?: number; // default: 1
222
+ }
223
+ ```
224
+
225
+ Example:
226
+
227
+ <div class="example">
228
+
229
+ ```json
230
+ {
231
+ "type": "range",
232
+ "name": "width",
233
+ "defaultValue": 5,
234
+ "min": 0,
235
+ "max": 10,
236
+ "step": 1
237
+ }
238
+ ```
239
+
240
+ </div>
241
+
242
+ ### Dual range
243
+
244
+ A range input with two handles that control minimum and maximum values. Includes the same additional properties as the range input.
245
+
246
+ ```ts
247
+ {
248
+ // Mandatory
249
+ min: number;
250
+ max: number;
251
+
252
+ // Optional
253
+ step?: number; // default: 1
254
+ }
255
+ ```
256
+
257
+ Example:
258
+
259
+ <div class="example">
260
+
261
+ ```json
262
+ {
263
+ "type": "dual-range",
264
+ "name": "size",
265
+ "defaultValue": { "min": 3, "max": 7 },
266
+ "min": 0,
267
+ "max": 10,
268
+ "step": 1
269
+ }
270
+ ```
271
+
272
+ </div>
273
+
274
+ ### Radio
275
+
276
+ A grid of radio buttons. Can be set to have between one and five columns.
277
+
278
+ ```ts
279
+ {
280
+ // Mandatory
281
+ items: Record<string, string>; // Map of radio items (key, value)
282
+
283
+ // Optional
284
+ columns?: number; // default: 3
285
+ }
286
+ ```
287
+
288
+ Example:
289
+
290
+ <div class="example">
291
+
292
+ ```json
293
+ {
294
+ "type": "radio",
295
+ "name": "main shape",
296
+ "items": {
297
+ "triangle": "3",
298
+ "rectangle": "4",
299
+ "hexagon": "6",
300
+ "octagon": "8",
301
+ "circle": "32"
302
+ },
303
+ "columns": 2
304
+ }
305
+ ```
306
+
307
+ </div>
308
+
309
+ ## Theming
310
+
311
+ Ctrls relies on CSS variables for theming. There is a lot of variables you can tweak, but I suggest starting with these four:
312
+
313
+ ```css
314
+ --ctrls-c: 0.7;
315
+ --ctrls-h: 220;
316
+ --ctrls-radius: 4px;
317
+ --ctrls-range-thumb-radius: 4px;
318
+ ```
319
+
320
+ <div class="theme-controls">
321
+ </div>
322
+
323
+ ## Why?
324
+
325
+ You might ask why I made another library when [dat.GUI](https://github.com/dataarts/dat.gui) and [TweakPane](https://tweakpane.github.io/docs/) already exist. They both solve a general use case, with extensive options and elaborate APIs, while I wanted something smaller and easier to adapt for [my algorithmic drawings](https://muffinman.io/art/).
326
+
327
+ I originally built a crude library with no plans to release it. Over time I polished it, ported it to TypeScript, and realized it might actually be useful to others. The code is solid, though I would love to add a few more features.
328
+
329
+ Ctrls is a very opinionated library tailored to my likings. I'm open to suggestions, but they need to fit algorithmic art workflows and align with my vision. It's not meant to be a general-purpose library - others already cover that space well.
330
+
331
+ I also want to mention that I really like TweakPane, and Ctrls' homepage is heavily influenced by it.
332
+
333
+ ## Cheers!
334
+
335
+ Thank you for stopping by! If you end up using Ctrls, please let me know, I would love to see it.
336
+
337
+ ## TODO
338
+
339
+ * [ ] Demo - favicon and metadata
340
+ * [ ] Readme - screenshots
341
+ * [ ] Prefix input names' with `ctrl_${id}_${name}` to avoid collisions
342
+ * [ ] Allow users to pass a custom PRNG lib
343
+ * [ ] Hash storage - check if there is an instance using hash storage already
344
+ * [ ] Demo - stop animation when not in viewport
345
+ * [ ] Add title which collapses the controls
346
+ * [ ] Storage - local storage
347
+ * [x] Use web safe / system fonts by default
348
+ * [x] Experiment with chroma for light and dark shades of the main color
349
+ * [x] On input event / handler
350
+ * [x] Revisit naming `controls` vs `options` vs `values`
351
+ * [x] Remove lucide as a dependency, swap with local SVGs
352
+ * [x] Add Aleas PRNG instead of seedrandom
353
+ * [x] Storage options - none
354
+ * [x] Fix get random value float point error in dual range (modulo with floats)
355
+ * [x] Easing - fix handles jumping to previous positions after selecting preset (no storage)
356
+ * [x] Scope the CSS
357
+ * [x] Controls -> Ctrls
358
+ * [x] Easing - option to pass custom presets (or an empty array to disable them)
359
+ * [x] onChange - maybe add current options (?) - decided not to do
@@ -0,0 +1,22 @@
1
+ import type { Ctrl, CtrlType, CtrlChangeHandler, CtrlConfig } from ".";
2
+ export declare class BooleanCtrl implements Ctrl<boolean> {
3
+ type: CtrlType;
4
+ name: string;
5
+ label: string;
6
+ value: boolean;
7
+ isRandomizationDisabled: boolean;
8
+ onChange: CtrlChangeHandler<boolean>;
9
+ onInput: CtrlChangeHandler<boolean>;
10
+ element: HTMLElement;
11
+ input: HTMLInputElement;
12
+ constructor(config: CtrlConfig<boolean>, onChange: CtrlChangeHandler<boolean>, onInput: CtrlChangeHandler<boolean>);
13
+ parse: (string: string) => string is "true";
14
+ getRandomValue: () => boolean;
15
+ getDefaultValue: () => boolean;
16
+ valueToString: (value?: boolean) => string;
17
+ buildUI: () => {
18
+ element: HTMLLabelElement;
19
+ input: HTMLInputElement;
20
+ };
21
+ update: (value: boolean) => void;
22
+ }
@@ -0,0 +1,67 @@
1
+ import { checkIcon } from "../utils/icons";
2
+ export class BooleanCtrl {
3
+ constructor(config, onChange, onInput) {
4
+ this.type = "boolean";
5
+ this.parse = (string) => {
6
+ return string === "true";
7
+ };
8
+ this.getRandomValue = () => {
9
+ return Math.random() > 0.5;
10
+ };
11
+ this.getDefaultValue = () => {
12
+ return true;
13
+ };
14
+ this.valueToString = (value = this.value) => {
15
+ return value.toString();
16
+ };
17
+ this.buildUI = () => {
18
+ const input = document.createElement("input");
19
+ input.classList.add("ctrls__boolean-input");
20
+ input.setAttribute("type", "checkbox");
21
+ input.checked = this.value;
22
+ input.addEventListener("change", () => {
23
+ this.value = input.checked;
24
+ this.onChange(this.name, this.value);
25
+ });
26
+ input.addEventListener("input", () => {
27
+ this.value = input.checked;
28
+ this.onInput(this.name, this.value);
29
+ });
30
+ const checkmark = document.createElement("span");
31
+ checkmark.classList.add("ctrls__boolean-checkmark");
32
+ checkmark.innerHTML = checkIcon;
33
+ const right = document.createElement("div");
34
+ right.classList.add("ctrls__control-right");
35
+ right.appendChild(input);
36
+ right.appendChild(checkmark);
37
+ const label = document.createElement("span");
38
+ label.textContent = this.label;
39
+ label.classList.add("ctrls__control-label");
40
+ const element = document.createElement("label");
41
+ element.classList.add("ctrls__control", "ctrls__control--boolean");
42
+ element.appendChild(label);
43
+ element.appendChild(right);
44
+ return {
45
+ element,
46
+ input,
47
+ };
48
+ };
49
+ this.update = (value) => {
50
+ this.value = value;
51
+ this.input.checked = this.value;
52
+ };
53
+ this.type = "boolean";
54
+ this.name = config.name;
55
+ this.label = config.label || config.name;
56
+ this.value =
57
+ config.defaultValue === undefined
58
+ ? this.getDefaultValue()
59
+ : config.defaultValue;
60
+ this.isRandomizationDisabled = config.isRandomizationDisabled || false;
61
+ this.onChange = onChange;
62
+ this.onInput = onInput;
63
+ const { input, element } = this.buildUI();
64
+ this.input = input;
65
+ this.element = element;
66
+ }
67
+ }
@@ -0,0 +1,47 @@
1
+ import DualRangeInput from "@stanko/dual-range-input";
2
+ import type { Ctrl, CtrlChangeHandler, CtrlType, CtrlTypeRegistry } from ".";
3
+ export type DualRangeControlOptions = {
4
+ min: number;
5
+ max: number;
6
+ step?: number;
7
+ };
8
+ export type DualRangeValue = {
9
+ min: number;
10
+ max: number;
11
+ };
12
+ export declare class DualRangeCtrl implements Ctrl<DualRangeValue> {
13
+ type: CtrlType;
14
+ name: string;
15
+ label: string;
16
+ value: DualRangeValue;
17
+ isRandomizationDisabled: boolean;
18
+ onChange: CtrlChangeHandler<DualRangeValue>;
19
+ onInput: CtrlChangeHandler<DualRangeValue>;
20
+ min: number;
21
+ max: number;
22
+ step: number;
23
+ element: HTMLElement;
24
+ minInput: HTMLInputElement;
25
+ maxInput: HTMLInputElement;
26
+ dualRange: DualRangeInput;
27
+ constructor(config: CtrlTypeRegistry["dual-range"]["config"], onChange: CtrlChangeHandler<DualRangeValue>, onInput: CtrlChangeHandler<DualRangeValue>);
28
+ parse: (string: string) => {
29
+ min: number;
30
+ max: number;
31
+ };
32
+ getRandomValue: () => {
33
+ min: number;
34
+ max: number;
35
+ };
36
+ getDefaultValue: () => {
37
+ min: number;
38
+ max: number;
39
+ };
40
+ valueToString: (value?: DualRangeValue) => string;
41
+ buildUI: () => {
42
+ element: HTMLDivElement;
43
+ minInput: HTMLInputElement;
44
+ maxInput: HTMLInputElement;
45
+ };
46
+ update: (value: DualRangeValue) => void;
47
+ }
@@ -0,0 +1,113 @@
1
+ import random from "../utils/random";
2
+ import DualRangeInput from "@stanko/dual-range-input";
3
+ import { roundToStep } from "../utils/round-to-step";
4
+ // TODO
5
+ // Add a span with the current value
6
+ export class DualRangeCtrl {
7
+ constructor(config, onChange, onInput) {
8
+ this.type = "dual-range";
9
+ this.parse = (string) => {
10
+ const [min, max] = string.split(",").map(parseFloat);
11
+ return { min, max };
12
+ };
13
+ this.getRandomValue = () => {
14
+ const { step } = this;
15
+ const min = random(this.min, this.max - step);
16
+ const max = random(min + step, this.max);
17
+ return {
18
+ min: roundToStep(min, step),
19
+ max: roundToStep(max, step),
20
+ };
21
+ };
22
+ this.getDefaultValue = () => {
23
+ return {
24
+ min: this.min,
25
+ max: this.max,
26
+ };
27
+ };
28
+ this.valueToString = (value = this.value) => {
29
+ return `${value.min},${value.max}`;
30
+ };
31
+ this.buildUI = () => {
32
+ const { min, max, step, value } = this;
33
+ const minInput = document.createElement("input");
34
+ minInput.setAttribute("type", "range");
35
+ minInput.setAttribute("min", min.toString());
36
+ minInput.setAttribute("max", max.toString());
37
+ minInput.setAttribute("step", step.toString());
38
+ minInput.setAttribute("value", value.min.toString());
39
+ minInput.addEventListener("input", () => {
40
+ this.value = {
41
+ min: parseFloat(minInput.value),
42
+ max: parseFloat(maxInput.value),
43
+ };
44
+ this.onInput(this.name, this.value);
45
+ });
46
+ minInput.addEventListener("change", () => {
47
+ this.value = {
48
+ min: parseFloat(minInput.value),
49
+ max: parseFloat(maxInput.value),
50
+ };
51
+ this.onChange(this.name, this.value);
52
+ });
53
+ const maxInput = document.createElement("input");
54
+ maxInput.setAttribute("type", "range");
55
+ maxInput.setAttribute("min", min.toString());
56
+ maxInput.setAttribute("max", max.toString());
57
+ maxInput.setAttribute("step", step.toString());
58
+ maxInput.setAttribute("value", value.max.toString());
59
+ maxInput.addEventListener("change", () => {
60
+ this.value = {
61
+ min: parseFloat(minInput.value),
62
+ max: parseFloat(maxInput.value),
63
+ };
64
+ this.onChange(this.name, this.value);
65
+ });
66
+ const inputWrapper = document.createElement("div");
67
+ inputWrapper.classList.add("dual-range-input");
68
+ inputWrapper.appendChild(minInput);
69
+ inputWrapper.appendChild(maxInput);
70
+ const right = document.createElement("div");
71
+ right.classList.add("ctrls__control-right");
72
+ right.appendChild(inputWrapper);
73
+ const label = document.createElement("span");
74
+ label.textContent = this.label;
75
+ label.classList.add("ctrls__control-label");
76
+ const element = document.createElement("div");
77
+ element.classList.add("ctrls__control", "ctrls__control--dual-range");
78
+ element.appendChild(label);
79
+ element.appendChild(right);
80
+ return {
81
+ element,
82
+ minInput,
83
+ maxInput,
84
+ };
85
+ };
86
+ this.update = (value) => {
87
+ const { min, max } = value;
88
+ this.value = value;
89
+ this.minInput.setAttribute("max", max.toString());
90
+ this.maxInput.setAttribute("min", min.toString());
91
+ this.minInput.value = value.min.toString();
92
+ this.maxInput.value = value.max.toString();
93
+ this.dualRange.update();
94
+ };
95
+ this.name = config.name;
96
+ this.label = config.label || config.name;
97
+ this.min = config.min;
98
+ this.max = config.max;
99
+ this.step = config.step || 1;
100
+ this.value =
101
+ config.defaultValue === undefined
102
+ ? this.getDefaultValue()
103
+ : config.defaultValue;
104
+ this.isRandomizationDisabled = config.isRandomizationDisabled || false;
105
+ this.onChange = onChange;
106
+ this.onInput = onInput;
107
+ const { minInput, maxInput, element } = this.buildUI();
108
+ this.minInput = minInput;
109
+ this.maxInput = maxInput;
110
+ this.element = element;
111
+ this.dualRange = new DualRangeInput(this.minInput, this.maxInput);
112
+ }
113
+ }
@@ -0,0 +1,37 @@
1
+ import type { Ctrl, CtrlChangeHandler, CtrlType, CtrlTypeRegistry } from ".";
2
+ export type Easing = [number, number, number, number];
3
+ export declare class EasingCtrl implements Ctrl<Easing> {
4
+ type: CtrlType;
5
+ name: string;
6
+ label: string;
7
+ value: Easing;
8
+ isRandomizationDisabled: boolean;
9
+ onChange: CtrlChangeHandler<Easing>;
10
+ onInput: CtrlChangeHandler<Easing>;
11
+ element: HTMLElement;
12
+ ticks: SVGLineElement[];
13
+ control: HTMLDivElement;
14
+ handles: HTMLButtonElement[];
15
+ lines: SVGLineElement[];
16
+ path: SVGPathElement;
17
+ presets: Record<string, Easing>;
18
+ constructor(config: CtrlTypeRegistry["easing"]["config"], onChange: CtrlChangeHandler<Easing>, onInput: CtrlChangeHandler<Easing>);
19
+ parse: (string: string) => Easing;
20
+ getRandomValue: () => Easing;
21
+ getDefaultValue: () => Easing;
22
+ valueToString: (value?: Easing) => string;
23
+ getRelativeValues: (value?: Easing) => {
24
+ px: Easing;
25
+ percentage: Easing;
26
+ };
27
+ buildUI: () => {
28
+ element: HTMLDivElement;
29
+ ticks: SVGLineElement[];
30
+ control: HTMLDivElement;
31
+ handles: HTMLButtonElement[];
32
+ lines: SVGLineElement[];
33
+ path: SVGPathElement;
34
+ };
35
+ updateUI: (value?: Easing) => void;
36
+ update: (value?: Easing) => void;
37
+ }