@tremolo-ui/functions 0.4.0 → 0.6.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.
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Options for setting the amount of keyboard and mouse wheel changes.
3
+ */
4
+ export type InputEventOption = readonly ['normalized' | 'raw', number]
5
+
6
+ /**
7
+ * A modifier key that can carry an amount of its own.
8
+ *
9
+ * `ctrl` and `meta` are kept apart rather than folded into one "command" key:
10
+ * a plugin UI that mirrors a desktop host usually wants the same physical key
11
+ * on every platform, not the platform's own convention.
12
+ */
13
+ export type Modifier = 'shift' | 'alt' | 'ctrl' | 'meta'
14
+
15
+ /** The modifier flags of a `WheelEvent` or a `KeyboardEvent`. */
16
+ export interface ModifierState {
17
+ shiftKey: boolean
18
+ altKey: boolean
19
+ ctrlKey: boolean
20
+ metaKey: boolean
21
+ }
22
+
23
+ /** One setting per modifier key, with `default` for none of them. */
24
+ type ModifierSetting = number | InputEventOption
25
+
26
+ export type ModifierMap<T extends ModifierSetting> = { default: T } & Partial<
27
+ Record<Modifier, T>
28
+ >
29
+
30
+ /**
31
+ * A single setting, or one per modifier key.
32
+ *
33
+ * @example
34
+ * ['raw', 1]
35
+ * { default: ['raw', 1], shift: ['raw', 0.1] }
36
+ */
37
+ export type ModifierValue<T extends ModifierSetting> = T | ModifierMap<T>
38
+
39
+ export interface SelectedInputEvent {
40
+ option: InputEventOption
41
+ /** Which modifier entry was chosen, or `null` for `default`. */
42
+ modifier: Modifier | null
43
+ }
44
+
45
+ /**
46
+ * Checked in this order, and the first one that is both held and configured
47
+ * wins. Fixing an order is what keeps two modifiers held at once from
48
+ * behaving differently between browsers.
49
+ */
50
+ const MODIFIER_ORDER = ['meta', 'ctrl', 'alt', 'shift'] as const
51
+
52
+ const MODIFIER_FLAG = {
53
+ meta: 'metaKey',
54
+ ctrl: 'ctrlKey',
55
+ alt: 'altKey',
56
+ shift: 'shiftKey',
57
+ } as const satisfies Record<Modifier, keyof ModifierState>
58
+
59
+ /**
60
+ * A map is the only form with a `default` key, which is what tells it apart
61
+ * from a bare setting. Tuples are arrays, so they never match.
62
+ */
63
+ function isModifierMap<T extends ModifierSetting>(
64
+ value: ModifierValue<T>,
65
+ ): value is ModifierMap<T> {
66
+ return (
67
+ typeof value === 'object' &&
68
+ value !== null &&
69
+ !Array.isArray(value) &&
70
+ 'default' in value
71
+ )
72
+ }
73
+
74
+ /**
75
+ * Pick the setting that applies, given the modifier keys being held.
76
+ *
77
+ * @example
78
+ * selectModifier({ default: 1, shift: 0.1 }, event)
79
+ */
80
+ export function selectModifier<T extends ModifierSetting>(
81
+ options: ModifierValue<T>,
82
+ modifiers?: ModifierState,
83
+ ): { value: T; modifier: Modifier | null } {
84
+ if (!isModifierMap(options)) {
85
+ // TypeScript cannot subtract the map from `ModifierValue<T>` while `T` is
86
+ // still a type parameter, so the other half has to be spelled out.
87
+ return { value: options as T, modifier: null }
88
+ }
89
+ if (modifiers) {
90
+ for (const modifier of MODIFIER_ORDER) {
91
+ const value = options[modifier]
92
+ // Compared against undefined rather than checked for truthiness: 0 is a
93
+ // legitimate setting.
94
+ if (value !== undefined && modifiers[MODIFIER_FLAG[modifier]]) {
95
+ return { value, modifier }
96
+ }
97
+ }
98
+ }
99
+ return { value: options.default, modifier: null }
100
+ }
101
+
102
+ /**
103
+ * Turn every entry of a setting into another kind of setting, keeping which
104
+ * modifier each belongs to.
105
+ *
106
+ * A drag sensitivity is a number and a keyboard amount is a tuple, but the two
107
+ * describe the same thing from the caller's side. This carries one over to the
108
+ * other so that a component can hand a sensitivity to {@link applyDelta}
109
+ * without unpicking the modifier map itself — which matters, since naming a
110
+ * modifier is also what takes `step` out of the pipeline.
111
+ *
112
+ * @example
113
+ * mapModifier({ default: 1, shift: 0.1 }, (f) => ['raw', step * f])
114
+ * // { default: ['raw', 1], shift: ['raw', 0.1] }
115
+ */
116
+ export function mapModifier<
117
+ T extends ModifierSetting,
118
+ U extends ModifierSetting,
119
+ >(options: ModifierValue<T>, fn: (value: T) => U): ModifierValue<U> {
120
+ if (!isModifierMap(options)) return fn(options as T)
121
+ const mapped = { default: fn(options.default) } as ModifierMap<U>
122
+ for (const modifier of MODIFIER_ORDER) {
123
+ const value = options[modifier]
124
+ if (value !== undefined) mapped[modifier] = fn(value)
125
+ }
126
+ return mapped
127
+ }
128
+
129
+ /**
130
+ * Pick the amount that applies, given the modifier keys being held.
131
+ *
132
+ * @example
133
+ * selectInputEvent({ default: ['raw', 1], shift: ['raw', 0.1] }, event)
134
+ */
135
+ export function selectInputEvent(
136
+ options: ModifierValue<InputEventOption>,
137
+ modifiers?: ModifierState,
138
+ ): SelectedInputEvent {
139
+ const { value, modifier } = selectModifier(options, modifiers)
140
+ return { option: value, modifier }
141
+ }
package/src/piano.ts ADDED
@@ -0,0 +1,177 @@
1
+ import { isBlackKey, isWhiteKey, noteKey, noteKeys, type NoteKey } from './midi'
2
+
3
+ export type NoteRange = {
4
+ first: number
5
+ last: number
6
+ }
7
+
8
+ /**
9
+ * `[noteRange.first, noteRange.first + 1, ..., noteRange.last]`
10
+ */
11
+ export function getNoteRangeArray(noteRange: NoteRange): number[] {
12
+ return Array.from(
13
+ { length: noteRange.last - noteRange.first + 1 },
14
+ (_, i) => i + noteRange.first,
15
+ )
16
+ }
17
+
18
+ /**
19
+ * The geometry of a drawn keyboard.
20
+ *
21
+ * One description is shared by the drawing and the hit testing, so a key cannot
22
+ * be drawn somewhere other than where it responds.
23
+ */
24
+ export interface PianoLayout {
25
+ noteRange: NoteRange
26
+
27
+ /** Width of a white key, excluding {@link PianoLayout.keyGap}. */
28
+ whiteKeyWidth: number
29
+
30
+ /**
31
+ * Space between two white keys. Part of the slot a white key occupies, so it
32
+ * still belongs to one of the keys for the purpose of hit testing.
33
+ *
34
+ * @default 1
35
+ */
36
+ keyGap?: number
37
+
38
+ /**
39
+ * Width of a black key, as a fraction of {@link PianoLayout.whiteKeyWidth}.
40
+ *
41
+ * @default 0.65
42
+ */
43
+ blackKeyWidthRatio?: number
44
+
45
+ /**
46
+ * Height of a black key, as a fraction of the height of the keyboard.
47
+ *
48
+ * @default 0.6
49
+ */
50
+ blackKeyHeightRatio?: number
51
+ }
52
+
53
+ const DEFAULT_KEY_GAP = 1
54
+ const DEFAULT_BLACK_KEY_WIDTH_RATIO = 0.65
55
+ const DEFAULT_BLACK_KEY_HEIGHT_RATIO = 0.6
56
+
57
+ /**
58
+ * How many white keys sit at or before each pitch class, counting from C.
59
+ *
60
+ * A black key shares the number of the white key to its left plus one, which
61
+ * puts it on the boundary between the two; {@link notePosition} then shifts it
62
+ * back by half its width to centre it there.
63
+ */
64
+ const whiteKeysBefore: Record<NoteKey, number> = {
65
+ C: 0,
66
+ 'C#': 1,
67
+ D: 1,
68
+ 'D#': 2,
69
+ E: 2,
70
+ F: 3,
71
+ 'F#': 4,
72
+ G: 4,
73
+ 'G#': 5,
74
+ A: 5,
75
+ 'A#': 6,
76
+ B: 6,
77
+ }
78
+
79
+ /** Width of a black key in pixels. */
80
+ export function blackKeyWidth(layout: PianoLayout): number {
81
+ return (
82
+ layout.whiteKeyWidth *
83
+ (layout.blackKeyWidthRatio ?? DEFAULT_BLACK_KEY_WIDTH_RATIO)
84
+ )
85
+ }
86
+
87
+ function rawNotePosition(note: number, layout: PianoLayout): number {
88
+ const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
89
+ const target = noteKey(note)
90
+ const first = noteKey(layout.noteRange.first)
91
+
92
+ const octave = Math.floor((note - layout.noteRange.first) / 12)
93
+ const octaveOffset =
94
+ noteKeys.indexOf(first) > noteKeys.indexOf(target) ? 1 : 0
95
+ const whiteKeysIn =
96
+ whiteKeysBefore[target] -
97
+ whiteKeysBefore[first] +
98
+ (octave + octaveOffset) * 7
99
+
100
+ return isBlackKey(note)
101
+ ? whiteKeysIn * slot - blackKeyWidth(layout) / 2
102
+ : whiteKeysIn * slot
103
+ }
104
+
105
+ function pianoBounds(layout: PianoLayout) {
106
+ const notes = getNoteRangeArray(layout.noteRange)
107
+ if (notes.length === 0) return { left: 0, right: 0 }
108
+
109
+ let left = Infinity
110
+ let right = -Infinity
111
+ const whiteWidth = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
112
+ for (const note of notes) {
113
+ const noteLeft = rawNotePosition(note, layout)
114
+ const width = isBlackKey(note) ? blackKeyWidth(layout) : whiteWidth
115
+ left = Math.min(left, noteLeft)
116
+ right = Math.max(right, noteLeft + width)
117
+ }
118
+ return { left, right }
119
+ }
120
+
121
+ /** Width of the whole keyboard in pixels. */
122
+ export function pianoWidth(layout: PianoLayout): number {
123
+ const { left, right } = pianoBounds(layout)
124
+ return right - left
125
+ }
126
+
127
+ /**
128
+ * Offset of the left edge of a key from the left edge of the keyboard, in
129
+ * pixels.
130
+ *
131
+ * Notes outside `noteRange` are placed too, so the value is negative below
132
+ * `noteRange.first`.
133
+ */
134
+ export function notePosition(note: number, layout: PianoLayout): number {
135
+ return rawNotePosition(note, layout) - pianoBounds(layout).left
136
+ }
137
+
138
+ /**
139
+ * The note drawn at a point, or null where there is none.
140
+ *
141
+ * Black keys are tested first, so they win where they overlap a white one. A
142
+ * white key covers its gap as well as its width, so the whole width of the
143
+ * keyboard belongs to some key and a click cannot fall between two.
144
+ *
145
+ * @param x offset from the left edge of the keyboard, in pixels
146
+ * @param y offset from its top edge, in pixels
147
+ * @param height height of the keyboard, in pixels
148
+ */
149
+ export function noteAt(
150
+ x: number,
151
+ y: number,
152
+ height: number,
153
+ layout: PianoLayout,
154
+ ): number | null {
155
+ if (x < 0 || x >= pianoWidth(layout) || y < 0 || y >= height) return null
156
+
157
+ const notes = getNoteRangeArray(layout.noteRange)
158
+ const blackHeight =
159
+ height * (layout.blackKeyHeightRatio ?? DEFAULT_BLACK_KEY_HEIGHT_RATIO)
160
+
161
+ if (y < blackHeight) {
162
+ for (const note of notes) {
163
+ if (isWhiteKey(note)) continue
164
+ const left = notePosition(note, layout)
165
+ if (left <= x && x < left + blackKeyWidth(layout)) return note
166
+ }
167
+ }
168
+
169
+ const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
170
+ for (const note of notes) {
171
+ if (isBlackKey(note)) continue
172
+ const left = notePosition(note, layout)
173
+ if (left <= x && x < left + slot) return note
174
+ }
175
+
176
+ return null
177
+ }
package/src/scales.ts ADDED
@@ -0,0 +1,338 @@
1
+ import { clamp, normalizeValue, rawValue, stepValue, toPrecision } from './math'
2
+ import {
3
+ type InputEventOption,
4
+ type ModifierState,
5
+ type ModifierValue,
6
+ selectInputEvent,
7
+ } from './modifiers'
8
+
9
+ /**
10
+ * How a value is distributed across the travel of a control.
11
+ *
12
+ * `normalize` and `denormalize` are inverses of each other: the position is
13
+ * 0 at `min` and 1 at `max`, and everything in between is up to the scale.
14
+ *
15
+ * `min` and `max` are arguments rather than baked into the scale, so a scale
16
+ * holds no state and can be a module level constant. Passing the same object
17
+ * on every render therefore costs nothing.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * exponentialScale.denormalize(0.5, 20, 20000) // 632.45…
22
+ * ```
23
+ */
24
+ export interface Scale {
25
+ /** Value to its position on the travel, 0-1. */
26
+ normalize: (value: number, min: number, max: number) => number
27
+ /** Position on the travel, 0-1, back to a value. */
28
+ denormalize: (position: number, min: number, max: number) => number
29
+ }
30
+
31
+ function assertRange(min: number, max: number) {
32
+ if (min >= max) throw new RangeError('requirements: min < max')
33
+ }
34
+
35
+ function assertPositiveFinite(value: number, name: string) {
36
+ if (!Number.isFinite(value) || value <= 0) {
37
+ throw new RangeError(`${name}: requirements: finite and greater than 0`)
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Equal travel gives an equal change in value.
43
+ *
44
+ * The right default for anything already linear in perception: dB values,
45
+ * pan, percentages, MIDI note numbers, semitones.
46
+ */
47
+ export const linearScale: Scale = {
48
+ normalize: (value, min, max) => normalizeValue(value, min, max),
49
+ denormalize: (position, min, max) => rawValue(position, min, max),
50
+ }
51
+
52
+ /**
53
+ * The power law of JUCE's `NormalisableRange::skew`, applied to `value - min`.
54
+ *
55
+ * Use it when the value has to agree with a JUCE or iPlug2 parameter — a
56
+ * plugin UI in a WebView, say, where the knob must sit exactly where the
57
+ * host's automation curve puts it. {@link skewWithCenterValue} gives the
58
+ * factor that places a chosen value at the middle of the travel.
59
+ *
60
+ * `skew < 1` gives the lower end more travel, `skew > 1` the upper end.
61
+ *
62
+ * For new designs prefer {@link exponentialScale} or {@link curveScale}: the
63
+ * slope of this curve is either zero or infinite at `min`, so the bottom of
64
+ * the range is a dead zone or jumps.
65
+ *
66
+ * @param skew the JUCE skew factor
67
+ */
68
+ export function skewScale(skew: number): Scale {
69
+ assertPositiveFinite(skew, 'skewScale')
70
+ return {
71
+ // The two expressions JUCE uses, kept verbatim so the numbers agree with
72
+ // a NormalisableRange: pow() one way, exp(log()) the other.
73
+ normalize: (value, min, max) =>
74
+ Math.pow(normalizeValue(value, min, max), skew),
75
+ denormalize: (position, min, max) =>
76
+ rawValue(
77
+ skew === 1
78
+ ? position
79
+ : Math.exp(Math.log(clamp(position, 0, 1)) / skew),
80
+ min,
81
+ max,
82
+ ),
83
+ }
84
+ }
85
+
86
+ /**
87
+ * The skew factor for {@link skewScale} that puts `centerValue` at the middle
88
+ * of the travel — JUCE's `NormalisableRange::setSkewForCentre`.
89
+ */
90
+ export function skewWithCenterValue(
91
+ centerValue: number,
92
+ min: number,
93
+ max: number,
94
+ ) {
95
+ assertRange(min, max)
96
+ if (!(min < centerValue && centerValue < max))
97
+ throw new RangeError('requirements: min < centerValue < max')
98
+ return Math.log(0.5) / Math.log((centerValue - min) / (max - min))
99
+ }
100
+
101
+ /**
102
+ * Equal travel gives an equal *ratio*, so an octave — or a percentage — takes
103
+ * the same distance wherever it falls.
104
+ *
105
+ * The scale for frequency (a filter cutoff over 20-20000 Hz), free running
106
+ * rates, and delay times.
107
+ *
108
+ * Requires `min` and `max` to be non-zero and of the same sign, since no
109
+ * ratio reaches zero or crosses it. Use {@link curveScale} for a range that
110
+ * starts at 0.
111
+ */
112
+ export const exponentialScale: Scale = {
113
+ normalize: (value, min, max) => {
114
+ assertExponentialRange(min, max)
115
+ // The value is clamped before the logarithm, not after: outside the range
116
+ // the ratio can be negative, and log() would give NaN rather than a
117
+ // position to clamp.
118
+ const start = Math.log(Math.abs(min))
119
+ const end = Math.log(Math.abs(max))
120
+ return clamp(
121
+ (Math.log(Math.abs(clamp(value, min, max))) - start) / (end - start),
122
+ 0,
123
+ 1,
124
+ )
125
+ },
126
+ denormalize: (position, min, max) => {
127
+ assertExponentialRange(min, max)
128
+ const start = Math.log(Math.abs(min))
129
+ const end = Math.log(Math.abs(max))
130
+ const magnitude = Math.exp(start + (end - start) * clamp(position, 0, 1))
131
+ return Math.sign(min) * magnitude
132
+ },
133
+ }
134
+
135
+ function assertExponentialRange(min: number, max: number) {
136
+ assertRange(min, max)
137
+ if (min === 0 || max === 0 || Math.sign(min) !== Math.sign(max)) {
138
+ throw new RangeError(
139
+ 'exponentialScale: requirements: min and max are non-zero and have the same sign',
140
+ )
141
+ }
142
+ }
143
+
144
+ /**
145
+ * An exponential bend that still passes exactly through `min` and `max`, so
146
+ * unlike {@link exponentialScale} it works on a range that starts at 0 or
147
+ * crosses it, and unlike {@link skewScale} its slope is neither zero nor
148
+ * infinite at either end.
149
+ *
150
+ * The general purpose taper, and the same family as the curve of an envelope
151
+ * segment (SuperCollider's `CurveWarp`).
152
+ *
153
+ * - `curve > 0` gives the lower end more travel — envelope times from 0 ms,
154
+ * delay times, anything that wants fine control near the bottom
155
+ * - `curve < 0` gives the upper end more travel — a volume fader over
156
+ * -60..+6 dB that should be precise around 0 dB
157
+ * - near 0 it is indistinguishable from {@link linearScale}, and is treated
158
+ * as linear to avoid dividing by zero
159
+ *
160
+ * {@link curveWithCenterValue} gives the curve that places a chosen value at
161
+ * the middle of the travel.
162
+ *
163
+ * @param curve how hard the curve bends, and in which direction
164
+ */
165
+ export function curveScale(curve: number): Scale {
166
+ // Beyond this the flatter half of the curve no longer has enough distinct
167
+ // double values for normalize and denormalize to remain inverses.
168
+ if (!Number.isFinite(curve) || Math.abs(curve) > 32) {
169
+ throw new RangeError(
170
+ 'curveScale: requirements: finite curve from -32 to 32',
171
+ )
172
+ }
173
+ // The two coefficients blow up as the curve flattens: `a` divides by
174
+ // 1 - e^curve, which goes to 0.
175
+ if (Math.abs(curve) < 0.001) return linearScale
176
+
177
+ return {
178
+ normalize: (value, min, max) => {
179
+ assertRange(min, max)
180
+ const proportion = clamp((value - min) / (max - min), 0, 1)
181
+ if (proportion === 0 || proportion === 1) return proportion
182
+ if (curve > 0) {
183
+ return (
184
+ 1 + Math.log(proportion + (1 - proportion) * Math.exp(-curve)) / curve
185
+ )
186
+ }
187
+ return Math.log1p(proportion * Math.expm1(curve)) / curve
188
+ },
189
+ denormalize: (position, min, max) => {
190
+ assertRange(min, max)
191
+ const p = clamp(position, 0, 1)
192
+ if (p === 0) return min
193
+ if (p === 1) return max
194
+ const proportion =
195
+ curve > 0
196
+ ? (Math.exp(curve * (p - 1)) * (1 - Math.exp(-curve * p))) /
197
+ (1 - Math.exp(-curve))
198
+ : Math.expm1(curve * p) / Math.expm1(curve)
199
+ return min + (max - min) * proportion
200
+ },
201
+ }
202
+ }
203
+
204
+ /**
205
+ * {@link skewScale} mirrored about the middle of the range, so both halves
206
+ * bend the same way — JUCE's `symmetricSkew`.
207
+ *
208
+ * For a bipolar control whose centre matters: detune over -100..+100 cents,
209
+ * or a bipolar filter envelope amount, where the fine adjustment is around 0
210
+ * rather than at either end.
211
+ *
212
+ * `skew < 1` gives the middle more travel, `skew > 1` the two ends.
213
+ *
214
+ * @param skew the JUCE skew factor
215
+ */
216
+ export function symmetricSkewScale(skew: number): Scale {
217
+ assertPositiveFinite(skew, 'symmetricSkewScale')
218
+ return {
219
+ normalize: (value, min, max) => {
220
+ assertRange(min, max)
221
+ const proportion = clamp((value - min) / (max - min), 0, 1)
222
+ if (skew === 1) return proportion
223
+ const distanceFromMiddle = 2 * proportion - 1
224
+ return (
225
+ (1 +
226
+ Math.pow(Math.abs(distanceFromMiddle), skew) *
227
+ Math.sign(distanceFromMiddle)) /
228
+ 2
229
+ )
230
+ },
231
+ denormalize: (position, min, max) => {
232
+ assertRange(min, max)
233
+ const p = clamp(position, 0, 1)
234
+ let distanceFromMiddle = 2 * p - 1
235
+ if (skew !== 1 && distanceFromMiddle !== 0) {
236
+ distanceFromMiddle =
237
+ Math.pow(Math.abs(distanceFromMiddle), 1 / skew) *
238
+ Math.sign(distanceFromMiddle)
239
+ }
240
+ return min + ((max - min) / 2) * (1 + distanceFromMiddle)
241
+ },
242
+ }
243
+ }
244
+
245
+ /**
246
+ * The curve for {@link curveScale} that puts `centerValue` at the middle of
247
+ * the travel — the counterpart of {@link skewWithCenterValue}.
248
+ */
249
+ export function curveWithCenterValue(
250
+ centerValue: number,
251
+ min: number,
252
+ max: number,
253
+ ) {
254
+ assertRange(min, max)
255
+ if (!(min < centerValue && centerValue < max)) {
256
+ throw new RangeError('requirements: min < centerValue < max')
257
+ }
258
+ // value(0.5) - min = range / (1 + e^(curve / 2))
259
+ const proportion = (centerValue - min) / (max - min)
260
+ return 2 * Math.log(1 / proportion - 1)
261
+ }
262
+
263
+ /**
264
+ * How a value is scaled: the range it lives in, how it is rounded, and how it
265
+ * is distributed across the travel.
266
+ *
267
+ * `AxisOptions` of `@tremolo-ui/dom` extends this, so a drag and a
268
+ * wheel / keyboard nudge run the same value pipeline.
269
+ */
270
+ export interface ValueRange {
271
+ min: number
272
+ max: number
273
+ /**
274
+ * Rounding applied to the value. Left unrounded when omitted.
275
+ */
276
+ step?: number
277
+ /**
278
+ * How the value is distributed across the travel.
279
+ *
280
+ * @default linearScale
281
+ */
282
+ scale?: Scale
283
+ }
284
+
285
+ /**
286
+ * Move a value by an amount of input, as reported by a wheel or an arrow key.
287
+ *
288
+ * The pipeline matches `createDragValue` of `@tremolo-ui/dom`: scale, then
289
+ * step, then clamp. Which key or which sign of `deltaY` counts as which
290
+ * direction is left to the caller, since it differs per component.
291
+ *
292
+ * @param direction which way, and how many times, to apply the option. The
293
+ * size of one step is `option[1]`, so this is normally `1` or `-1`.
294
+ *
295
+ * @param modifiers the event, for `options` that name a modifier key. See
296
+ * {@link selectInputEvent}.
297
+ *
298
+ * @example
299
+ * // ArrowDown on a slider whose keyboard option is ['raw', 1]
300
+ * applyDelta(value, -1, keyboard, { min, max, step, scale })
301
+ *
302
+ * @example
303
+ * // Shift+ArrowDown, where `keyboard` is { default: …, shift: ['raw', 0.1] }
304
+ * applyDelta(value, -1, keyboard, range, event)
305
+ */
306
+ export function applyDelta(
307
+ value: number,
308
+ direction: number,
309
+ options: ModifierValue<InputEventOption>,
310
+ { min, max, step, scale = linearScale }: ValueRange,
311
+ modifiers?: ModifierState,
312
+ ): number {
313
+ assertRange(min, max)
314
+ if (step !== undefined) assertPositiveFinite(step, 'applyDelta step')
315
+
316
+ const {
317
+ option: [mode, amount],
318
+ modifier,
319
+ } = selectInputEvent(options, modifiers)
320
+
321
+ const x = direction * amount
322
+ const next =
323
+ mode === 'normalized'
324
+ ? scale.denormalize(scale.normalize(value, min, max) + x, min, max)
325
+ : value + x
326
+
327
+ // Naming a modifier is a deliberate request to move off the grid, so `step`
328
+ // does not apply to it. Without this a finer amount would round straight
329
+ // back to where it started: `stepValue(3 + 0.1, 1)` is 3.
330
+ const quantum = modifier === null ? step : undefined
331
+ const stepped = quantum !== undefined ? stepValue(next, quantum) : next
332
+
333
+ // Rounded before the clamp, so that `min` and `max` still have the last
334
+ // word and the value can land on them exactly. Without this the artefact
335
+ // accumulates: with no `step` to round it back, twelve presses of a 0.1
336
+ // modifier amount reach 5.699999999999998 rather than 5.7.
337
+ return clamp(toPrecision(stepped), min, max)
338
+ }