@tremolo-ui/functions 0.3.0 → 0.5.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/src/index.ts CHANGED
@@ -9,13 +9,26 @@ export {
9
9
  normalizeValue,
10
10
  radian,
11
11
  rawValue,
12
- skewWithCenterValue,
13
12
  stepValue,
14
13
  toFixed,
15
14
  } from './math'
15
+ export {
16
+ type Scale,
17
+ type ValueRange,
18
+ applyDelta,
19
+ curveScale,
20
+ curveWithCenterValue,
21
+ exponentialScale,
22
+ linearScale,
23
+ skewScale,
24
+ skewWithCenterValue,
25
+ symmetricSkewScale,
26
+ } from './scales'
16
27
  export {
17
28
  type NoteKey,
29
+ type ScaleName,
18
30
  type WhiteKey,
31
+ inScale,
19
32
  isBlackKey,
20
33
  isWhiteKey,
21
34
  noteKey,
@@ -24,7 +37,19 @@ export {
24
37
  noteNumber,
25
38
  noteToFrequency,
26
39
  parseNoteName,
40
+ scaleIntervals,
41
+ scaleNotes,
27
42
  whiteKeys,
28
43
  } from './midi'
44
+ export {
45
+ type NoteRange,
46
+ type PianoLayout,
47
+ blackKeyWidth,
48
+ getNoteRangeArray,
49
+ noteAt,
50
+ notePosition,
51
+ pianoWidth,
52
+ } from './piano'
29
53
  export { type InputEventOption } from './types'
54
+ export { type Units, formatValue, parseValue, selectUnit } from './unit'
30
55
  export { isEmpty, mod, styleHelper, xor } from './util'
package/src/math.ts CHANGED
@@ -6,44 +6,24 @@ export function clamp(value: number, min: number, max: number) {
6
6
  }
7
7
 
8
8
  /**
9
- * Normalize the value from 0 to 1
9
+ * Normalize the value from 0 to 1, spreading the range evenly.
10
+ *
11
+ * This is the linear mapping and takes no curve of its own; a `Scale` builds
12
+ * whatever curve it needs on top of it.
10
13
  */
11
- export function normalizeValue(
12
- rawValue: number,
13
- min: number,
14
- max: number,
15
- skew = 1,
16
- ) {
14
+ export function normalizeValue(value: number, min: number, max: number) {
17
15
  if (min >= max) throw new RangeError('requirements: min < max')
18
- const v = clamp((rawValue - min) / (max - min), 0, 1)
19
- return Math.pow(v, skew)
16
+ return clamp((value - min) / (max - min), 0, 1)
20
17
  }
21
18
 
22
19
  /**
23
- * Convert normalized values back to raw values.
20
+ * Convert normalized values back to raw values, spreading the range evenly.
21
+ *
22
+ * The inverse of {@link normalizeValue}.
24
23
  */
25
- export function rawValue(
26
- normalizedValue: number,
27
- min: number,
28
- max: number,
29
- skew = 1,
30
- ) {
24
+ export function rawValue(normalizedValue: number, min: number, max: number) {
31
25
  if (min >= max) throw new RangeError('requirements: min < max')
32
- const v =
33
- skew == 1
34
- ? clamp(normalizedValue, 0, 1)
35
- : Math.exp(Math.log(clamp(normalizedValue, 0, 1)) / skew)
36
- return min + v * (max - min)
37
- }
38
-
39
- export function skewWithCenterValue(
40
- centerValue: number,
41
- min: number,
42
- max: number,
43
- ) {
44
- if (!(min <= centerValue && centerValue <= max))
45
- throw new RangeError('requirements: min <= centerValue <= max')
46
- return Math.log(0.5) / Math.log((centerValue - min) / (max - min))
26
+ return min + clamp(normalizedValue, 0, 1) * (max - min)
47
27
  }
48
28
 
49
29
  export function stepValue(value: number, step: number) {
package/src/midi.ts CHANGED
@@ -94,3 +94,85 @@ export function noteToFrequency(note: number | string, detune = 0, a4 = 440) {
94
94
  const n = typeof note == 'string' ? noteNumber(note) : note
95
95
  return (a4 / 32) * 2 ** ((n - 9 + detune / 100) / 12)
96
96
  }
97
+
98
+ /**
99
+ * Semitones above the root, for each supported scale.
100
+ *
101
+ * Every entry starts at 0 and stays inside one octave, so a scale is a set of
102
+ * pitch classes rather than a set of notes: {@link inScale} compares against
103
+ * it with the octave taken out.
104
+ *
105
+ * `ionian` and `aeolian` are the same sets as `major` and `naturalMinor`; both
106
+ * spellings are here because both are what someone reaches for depending on
107
+ * whether they are thinking in keys or in modes.
108
+ */
109
+ export const scaleIntervals = {
110
+ major: [0, 2, 4, 5, 7, 9, 11],
111
+ naturalMinor: [0, 2, 3, 5, 7, 8, 10],
112
+ harmonicMinor: [0, 2, 3, 5, 7, 8, 11],
113
+ melodicMinor: [0, 2, 3, 5, 7, 9, 11],
114
+
115
+ ionian: [0, 2, 4, 5, 7, 9, 11],
116
+ dorian: [0, 2, 3, 5, 7, 9, 10],
117
+ phrygian: [0, 1, 3, 5, 7, 8, 10],
118
+ lydian: [0, 2, 4, 6, 7, 9, 11],
119
+ mixolydian: [0, 2, 4, 5, 7, 9, 10],
120
+ aeolian: [0, 2, 3, 5, 7, 8, 10],
121
+ locrian: [0, 1, 3, 5, 6, 8, 10],
122
+
123
+ majorPentatonic: [0, 2, 4, 7, 9],
124
+ minorPentatonic: [0, 3, 5, 7, 10],
125
+ blues: [0, 3, 5, 6, 7, 10],
126
+
127
+ wholeTone: [0, 2, 4, 6, 8, 10],
128
+ chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
129
+ } as const satisfies Record<string, readonly number[]>
130
+
131
+ export type ScaleName = keyof typeof scaleIntervals
132
+
133
+ /**
134
+ * Whether a note belongs to a scale, regardless of the octave either sits in.
135
+ *
136
+ * @param note noteNumber: 0 ~ 127 or noteName e.g. 'C3'
137
+ * @param root the note the scale is built on, in the same two forms
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * inScale('F#4', 'D3', 'major') // true
142
+ * ```
143
+ */
144
+ export function inScale(
145
+ note: number | string,
146
+ root: number | string,
147
+ name: ScaleName,
148
+ ): boolean {
149
+ const n = typeof note == 'string' ? noteNumber(note) : note
150
+ const r = typeof root == 'string' ? noteNumber(root) : root
151
+ return (scaleIntervals[name] as readonly number[]).includes(mod(n - r, 12))
152
+ }
153
+
154
+ /**
155
+ * The notes of a scale, ascending from `root`.
156
+ *
157
+ * The octave above the root is not included: ask for more `octaves` instead, so
158
+ * that concatenating the result of two calls does not repeat a note.
159
+ *
160
+ * @param root noteNumber: 0 ~ 127 or noteName e.g. 'C3'
161
+ * @param octaves how many octaves to cover
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * scaleNotes('C3', 'majorPentatonic') // [48, 50, 52, 55, 57]
166
+ * ```
167
+ */
168
+ export function scaleNotes(
169
+ root: number | string,
170
+ name: ScaleName,
171
+ octaves = 1,
172
+ ): number[] {
173
+ const r = typeof root == 'string' ? noteNumber(root) : root
174
+ const intervals = scaleIntervals[name] as readonly number[]
175
+ return Array.from({ length: octaves }, (_, octave) =>
176
+ intervals.map((interval) => r + octave * 12 + interval),
177
+ ).flat()
178
+ }
package/src/piano.ts ADDED
@@ -0,0 +1,163 @@
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
+ /** Width of the whole keyboard in pixels. */
88
+ export function pianoWidth(layout: PianoLayout): number {
89
+ const whiteKeys = getNoteRangeArray(layout.noteRange).filter(isWhiteKey)
90
+ return (
91
+ (layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)) *
92
+ whiteKeys.length
93
+ )
94
+ }
95
+
96
+ /**
97
+ * Offset of the left edge of a key from the left edge of the keyboard, in
98
+ * pixels.
99
+ *
100
+ * Notes outside `noteRange` are placed too, so the value is negative below
101
+ * `noteRange.first`.
102
+ */
103
+ export function notePosition(note: number, layout: PianoLayout): number {
104
+ const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
105
+ const target = noteKey(note)
106
+ const first = noteKey(layout.noteRange.first)
107
+
108
+ const octave = Math.floor((note - layout.noteRange.first) / 12)
109
+ // A note whose pitch class comes before the first one belongs to the octave
110
+ // above the one the division above gives.
111
+ const octaveOffset =
112
+ noteKeys.indexOf(first) > noteKeys.indexOf(target) ? 1 : 0
113
+
114
+ const whiteKeysIn =
115
+ whiteKeysBefore[target] -
116
+ whiteKeysBefore[first] +
117
+ (octave + octaveOffset) * 7
118
+
119
+ return isBlackKey(note)
120
+ ? whiteKeysIn * slot - blackKeyWidth(layout) / 2
121
+ : whiteKeysIn * slot
122
+ }
123
+
124
+ /**
125
+ * The note drawn at a point, or null where there is none.
126
+ *
127
+ * Black keys are tested first, so they win where they overlap a white one. A
128
+ * white key covers its gap as well as its width, so the whole width of the
129
+ * keyboard belongs to some key and a click cannot fall between two.
130
+ *
131
+ * @param x offset from the left edge of the keyboard, in pixels
132
+ * @param y offset from its top edge, in pixels
133
+ * @param height height of the keyboard, in pixels
134
+ */
135
+ export function noteAt(
136
+ x: number,
137
+ y: number,
138
+ height: number,
139
+ layout: PianoLayout,
140
+ ): number | null {
141
+ if (y < 0 || y >= height) return null
142
+
143
+ const notes = getNoteRangeArray(layout.noteRange)
144
+ const blackHeight =
145
+ height * (layout.blackKeyHeightRatio ?? DEFAULT_BLACK_KEY_HEIGHT_RATIO)
146
+
147
+ if (y < blackHeight) {
148
+ for (const note of notes) {
149
+ if (isWhiteKey(note)) continue
150
+ const left = notePosition(note, layout)
151
+ if (left <= x && x < left + blackKeyWidth(layout)) return note
152
+ }
153
+ }
154
+
155
+ const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
156
+ for (const note of notes) {
157
+ if (isBlackKey(note)) continue
158
+ const left = notePosition(note, layout)
159
+ if (left <= x && x < left + slot) return note
160
+ }
161
+
162
+ return null
163
+ }
package/src/scales.ts ADDED
@@ -0,0 +1,284 @@
1
+ import { clamp, normalizeValue, rawValue, stepValue } from './math'
2
+
3
+ import type { InputEventOption } from './types'
4
+
5
+ /**
6
+ * How a value is distributed across the travel of a control.
7
+ *
8
+ * `normalize` and `denormalize` are inverses of each other: the position is
9
+ * 0 at `min` and 1 at `max`, and everything in between is up to the scale.
10
+ *
11
+ * `min` and `max` are arguments rather than baked into the scale, so a scale
12
+ * holds no state and can be a module level constant. Passing the same object
13
+ * on every render therefore costs nothing.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * exponentialScale.denormalize(0.5, 20, 20000) // 632.45…
18
+ * ```
19
+ */
20
+ export interface Scale {
21
+ /** Value to its position on the travel, 0-1. */
22
+ normalize: (value: number, min: number, max: number) => number
23
+ /** Position on the travel, 0-1, back to a value. */
24
+ denormalize: (position: number, min: number, max: number) => number
25
+ }
26
+
27
+ function assertRange(min: number, max: number) {
28
+ if (min >= max) throw new RangeError('requirements: min < max')
29
+ }
30
+
31
+ /**
32
+ * Equal travel gives an equal change in value.
33
+ *
34
+ * The right default for anything already linear in perception: dB values,
35
+ * pan, percentages, MIDI note numbers, semitones.
36
+ */
37
+ export const linearScale: Scale = {
38
+ normalize: (value, min, max) => normalizeValue(value, min, max),
39
+ denormalize: (position, min, max) => rawValue(position, min, max),
40
+ }
41
+
42
+ /**
43
+ * The power law of JUCE's `NormalisableRange::skew`, applied to `value - min`.
44
+ *
45
+ * Use it when the value has to agree with a JUCE or iPlug2 parameter — a
46
+ * plugin UI in a WebView, say, where the knob must sit exactly where the
47
+ * host's automation curve puts it. {@link skewWithCenterValue} gives the
48
+ * factor that places a chosen value at the middle of the travel.
49
+ *
50
+ * `skew < 1` gives the lower end more travel, `skew > 1` the upper end.
51
+ *
52
+ * For new designs prefer {@link exponentialScale} or {@link curveScale}: the
53
+ * slope of this curve is either zero or infinite at `min`, so the bottom of
54
+ * the range is a dead zone or jumps.
55
+ *
56
+ * @param skew the JUCE skew factor
57
+ */
58
+ export function skewScale(skew: number): Scale {
59
+ return {
60
+ // The two expressions JUCE uses, kept verbatim so the numbers agree with
61
+ // a NormalisableRange: pow() one way, exp(log()) the other.
62
+ normalize: (value, min, max) =>
63
+ Math.pow(normalizeValue(value, min, max), skew),
64
+ denormalize: (position, min, max) =>
65
+ rawValue(
66
+ skew === 1
67
+ ? position
68
+ : Math.exp(Math.log(clamp(position, 0, 1)) / skew),
69
+ min,
70
+ max,
71
+ ),
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The skew factor for {@link skewScale} that puts `centerValue` at the middle
77
+ * of the travel — JUCE's `NormalisableRange::setSkewForCentre`.
78
+ */
79
+ export function skewWithCenterValue(
80
+ centerValue: number,
81
+ min: number,
82
+ max: number,
83
+ ) {
84
+ if (!(min <= centerValue && centerValue <= max))
85
+ throw new RangeError('requirements: min <= centerValue <= max')
86
+ return Math.log(0.5) / Math.log((centerValue - min) / (max - min))
87
+ }
88
+
89
+ /**
90
+ * Equal travel gives an equal *ratio*, so an octave — or a percentage — takes
91
+ * the same distance wherever it falls.
92
+ *
93
+ * The scale for frequency (a filter cutoff over 20-20000 Hz), free running
94
+ * rates, and delay times.
95
+ *
96
+ * Requires `min` and `max` to be non-zero and of the same sign, since no
97
+ * ratio reaches zero or crosses it. Use {@link curveScale} for a range that
98
+ * starts at 0.
99
+ */
100
+ export const exponentialScale: Scale = {
101
+ normalize: (value, min, max) => {
102
+ assertExponentialRange(min, max)
103
+ // The value is clamped before the logarithm, not after: outside the range
104
+ // the ratio can be negative, and log() would give NaN rather than a
105
+ // position to clamp.
106
+ return clamp(
107
+ Math.log(clamp(value, min, max) / min) / Math.log(max / min),
108
+ 0,
109
+ 1,
110
+ )
111
+ },
112
+ denormalize: (position, min, max) => {
113
+ assertExponentialRange(min, max)
114
+ return min * Math.pow(max / min, clamp(position, 0, 1))
115
+ },
116
+ }
117
+
118
+ function assertExponentialRange(min: number, max: number) {
119
+ assertRange(min, max)
120
+ if (min === 0 || max === 0 || Math.sign(min) !== Math.sign(max)) {
121
+ throw new RangeError(
122
+ 'exponentialScale: requirements: min and max are non-zero and have the same sign',
123
+ )
124
+ }
125
+ }
126
+
127
+ /**
128
+ * An exponential bend that still passes exactly through `min` and `max`, so
129
+ * unlike {@link exponentialScale} it works on a range that starts at 0 or
130
+ * crosses it, and unlike {@link skewScale} its slope is neither zero nor
131
+ * infinite at either end.
132
+ *
133
+ * The general purpose taper, and the same family as the curve of an envelope
134
+ * segment (SuperCollider's `CurveWarp`).
135
+ *
136
+ * - `curve > 0` gives the lower end more travel — envelope times from 0 ms,
137
+ * delay times, anything that wants fine control near the bottom
138
+ * - `curve < 0` gives the upper end more travel — a volume fader over
139
+ * -60..+6 dB that should be precise around 0 dB
140
+ * - near 0 it is indistinguishable from {@link linearScale}, and is treated
141
+ * as linear to avoid dividing by zero
142
+ *
143
+ * {@link curveWithCenterValue} gives the curve that places a chosen value at
144
+ * the middle of the travel.
145
+ *
146
+ * @param curve how hard the curve bends, and in which direction
147
+ */
148
+ export function curveScale(curve: number): Scale {
149
+ // The two coefficients blow up as the curve flattens: `a` divides by
150
+ // 1 - e^curve, which goes to 0.
151
+ if (Math.abs(curve) < 0.001) return linearScale
152
+
153
+ const grow = Math.exp(curve)
154
+
155
+ // value(position) = b - a * e^(curve * position), fixed so that
156
+ // value(0) = min and value(1) = max.
157
+ const coefficients = (min: number, max: number) => {
158
+ const a = (max - min) / (1 - grow)
159
+ return { a, b: min + a }
160
+ }
161
+
162
+ return {
163
+ normalize: (value, min, max) => {
164
+ assertRange(min, max)
165
+ const { a, b } = coefficients(min, max)
166
+ // Clamped before the logarithm: far outside the range `(b - value) / a`
167
+ // turns negative and log() would give NaN.
168
+ return clamp(Math.log((b - clamp(value, min, max)) / a) / curve, 0, 1)
169
+ },
170
+ denormalize: (position, min, max) => {
171
+ assertRange(min, max)
172
+ const { a, b } = coefficients(min, max)
173
+ return b - a * Math.pow(grow, clamp(position, 0, 1))
174
+ },
175
+ }
176
+ }
177
+
178
+ /**
179
+ * {@link skewScale} mirrored about the middle of the range, so both halves
180
+ * bend the same way — JUCE's `symmetricSkew`.
181
+ *
182
+ * For a bipolar control whose centre matters: detune over -100..+100 cents,
183
+ * or a bipolar filter envelope amount, where the fine adjustment is around 0
184
+ * rather than at either end.
185
+ *
186
+ * `skew < 1` gives the middle more travel, `skew > 1` the two ends.
187
+ *
188
+ * @param skew the JUCE skew factor
189
+ */
190
+ export function symmetricSkewScale(skew: number): Scale {
191
+ return {
192
+ normalize: (value, min, max) => {
193
+ assertRange(min, max)
194
+ const proportion = clamp((value - min) / (max - min), 0, 1)
195
+ if (skew === 1) return proportion
196
+ const distanceFromMiddle = 2 * proportion - 1
197
+ return (
198
+ (1 +
199
+ Math.pow(Math.abs(distanceFromMiddle), skew) *
200
+ Math.sign(distanceFromMiddle)) /
201
+ 2
202
+ )
203
+ },
204
+ denormalize: (position, min, max) => {
205
+ assertRange(min, max)
206
+ const p = clamp(position, 0, 1)
207
+ let distanceFromMiddle = 2 * p - 1
208
+ if (skew !== 1 && distanceFromMiddle !== 0) {
209
+ distanceFromMiddle =
210
+ Math.pow(Math.abs(distanceFromMiddle), 1 / skew) *
211
+ Math.sign(distanceFromMiddle)
212
+ }
213
+ return min + ((max - min) / 2) * (1 + distanceFromMiddle)
214
+ },
215
+ }
216
+ }
217
+
218
+ /**
219
+ * The curve for {@link curveScale} that puts `centerValue` at the middle of
220
+ * the travel — the counterpart of {@link skewWithCenterValue}.
221
+ */
222
+ export function curveWithCenterValue(
223
+ centerValue: number,
224
+ min: number,
225
+ max: number,
226
+ ) {
227
+ assertRange(min, max)
228
+ if (!(min < centerValue && centerValue < max)) {
229
+ throw new RangeError('requirements: min < centerValue < max')
230
+ }
231
+ // value(0.5) - min = range / (1 + e^(curve / 2))
232
+ const proportion = (centerValue - min) / (max - min)
233
+ return 2 * Math.log(1 / proportion - 1)
234
+ }
235
+
236
+ /**
237
+ * How a value is scaled: the range it lives in, how it is rounded, and how it
238
+ * is distributed across the travel.
239
+ *
240
+ * `AxisOptions` of `@tremolo-ui/dom` extends this, so a drag and a
241
+ * wheel / keyboard nudge run the same value pipeline.
242
+ */
243
+ export interface ValueRange {
244
+ min: number
245
+ max: number
246
+ /**
247
+ * Rounding applied to the value. Left unrounded when omitted.
248
+ */
249
+ step?: number
250
+ /**
251
+ * How the value is distributed across the travel.
252
+ *
253
+ * @default linearScale
254
+ */
255
+ scale?: Scale
256
+ }
257
+
258
+ /**
259
+ * Move a value by an amount of input, as reported by a wheel or an arrow key.
260
+ *
261
+ * The pipeline matches `createDragValue` of `@tremolo-ui/dom`: scale, then
262
+ * step, then clamp. Which key or which sign of `deltaY` counts as which
263
+ * direction is left to the caller, since it differs per component.
264
+ *
265
+ * @param direction which way, and how many times, to apply the option. The
266
+ * size of one step is `option[1]`, so this is normally `1` or `-1`.
267
+ *
268
+ * @example
269
+ * // ArrowDown on a slider whose keyboard option is ['raw', 1]
270
+ * applyDelta(value, -1, keyboard, { min, max, step, scale })
271
+ */
272
+ export function applyDelta(
273
+ value: number,
274
+ direction: number,
275
+ [mode, amount]: InputEventOption,
276
+ { min, max, step, scale = linearScale }: ValueRange,
277
+ ): number {
278
+ const x = direction * amount
279
+ const next =
280
+ mode == 'normalized'
281
+ ? scale.denormalize(scale.normalize(value, min, max) + x, min, max)
282
+ : value + x
283
+ return clamp(step ? stepValue(next, step) : next, min, max)
284
+ }
package/src/unit.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Units a value can be displayed in, as `[symbol, scale]` pairs ordered from
3
+ * the smallest scale up. A value is shown in the largest unit that does not
4
+ * exceed it.
5
+ *
6
+ * @example
7
+ * [['Hz', 1], ['kHz', 1000]]
8
+ * [['ms', 1], ['s', 1000]]
9
+ */
10
+ export type Units = [string, number][]
11
+
12
+ /**
13
+ * Pick the unit a value is displayed in: the largest one whose scale does not
14
+ * exceed the magnitude of the value.
15
+ */
16
+ export function selectUnit(units: Units, value: number): [string, number] {
17
+ let i = 0
18
+ for (; i < units.length; i++) {
19
+ if (Math.abs(units[i][1]) > Math.abs(value)) break
20
+ }
21
+ return units[Math.max(0, i - 1)]
22
+ }
23
+
24
+ /**
25
+ * Render a value as text, in the unit that suits its magnitude.
26
+ *
27
+ * @param units a single symbol appended as-is, or a list to choose from.
28
+ * @param digit digits after the decimal point. Left as-is when omitted.
29
+ *
30
+ * @example
31
+ * formatValue(1234, [['Hz', 1], ['kHz', 1000]], 2) // '1.23kHz'
32
+ * formatValue(1.5, 'Hz') // '1.5Hz'
33
+ */
34
+ export function formatValue(
35
+ value: number,
36
+ units?: string | Units,
37
+ digit?: number,
38
+ ): string {
39
+ const fixed = (v: number) =>
40
+ digit != undefined ? v.toFixed(digit) : String(v)
41
+ if (!units || typeof units == 'string') {
42
+ return fixed(value) + (units ?? '')
43
+ }
44
+ const [unit, scale] = selectUnit(units, value)
45
+ return fixed(value / scale) + unit
46
+ }
47
+
48
+ /**
49
+ * Read a value back out of text, undoing the scaling of {@link formatValue}.
50
+ *
51
+ * With a list of units the text has to be a number followed by an optional
52
+ * unit and nothing else, since the unit decides the scale; anything else reads
53
+ * as 0. With a single unit, or none, the first number found anywhere in the
54
+ * text is taken, so a half-typed entry still yields something.
55
+ *
56
+ * @example
57
+ * parseValue('1.23kHz', [['Hz', 1], ['kHz', 1000]]) // 1230
58
+ * parseValue('4abc') // 4
59
+ */
60
+ export function parseValue(text: string, units?: string | Units): number {
61
+ const str = text.trim()
62
+
63
+ if (!units || typeof units == 'string') {
64
+ const m = str.match(/-?\d+(\.\d+)?/)
65
+ const v = Number(m?.[0] ?? '0')
66
+ return isNaN(v) ? 0 : v
67
+ }
68
+
69
+ const m = str.match(/^(-?\d+(\.\d+)?)\s*(\w*)$/)
70
+ if (!m) return 0
71
+ const found = units.find(([unit]) => unit == m[3])
72
+ return (Number(m[1]) || 0) * (found ? found[1] : 1)
73
+ }