@tremolo-ui/functions 0.5.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.
package/dist/index.js CHANGED
@@ -24,17 +24,55 @@ function rawValue(normalizedValue, min, max) {
24
24
  if (min >= max) throw new RangeError("requirements: min < max");
25
25
  return min + clamp(normalizedValue, 0, 1) * (max - min);
26
26
  }
27
+ /**
28
+ * Put a value on the grid the caller asked for, rounding a half step upwards.
29
+ *
30
+ * The rounding is done on the quotient rather than by comparing the distance
31
+ * to the two neighbours, because both of those carry error of their own. The
32
+ * quotient is cleared of its artefact first: `0.15 / 0.1` is 1.4999999999999998,
33
+ * and a value sitting exactly on a half step would otherwise fall to whichever
34
+ * side the last bit happened to land on — 0.25 rounded up while 0.15 and 0.35
35
+ * rounded down.
36
+ */
27
37
  function stepValue(value, step) {
28
38
  if (step <= 0) throw new RangeError("requirements: step > 0");
29
- const quotient = Math.floor(value / step);
30
- const decimalDigits = decimalPart(step)?.length;
31
- const v = toFixed(quotient * step, decimalDigits);
32
- const next = toFixed((quotient + 1) * step, decimalDigits);
33
- return Math.abs(value - v) < Math.abs(value - next) ? v : next;
39
+ const stepped = toPrecision(Math.round(toPrecision(value / step)) * step);
40
+ return stepped === 0 ? 0 : stepped;
34
41
  }
35
42
  function toFixed(x, fractionDigits) {
36
43
  return Number(x.toFixed(fractionDigits));
37
44
  }
45
+ /**
46
+ * The significant decimal digits a double actually carries. A double holds a
47
+ * little under 16, so anything past this is the binary representation showing
48
+ * through rather than information.
49
+ */
50
+ const SIGNIFICANT_DIGITS = 15;
51
+ /**
52
+ * Drop the binary artefact from a computed value.
53
+ *
54
+ * Arithmetic on doubles leaves debris in the last couple of digits, and it
55
+ * accumulates: adding 0.1 to 5 twelve times gives 5.699999999999998 rather
56
+ * than 5.7, and the display of a control shows exactly that. Rounding to the
57
+ * digits a double can carry removes it, and adds nothing back — the value was
58
+ * already the result of a calculation whose own error is that size or larger.
59
+ *
60
+ * This is not the same as rounding to a `step`. {@link stepValue} puts a value
61
+ * on a grid the caller asked for and is a decision about the value; this only
62
+ * removes what was never in the value to begin with.
63
+ *
64
+ * @param significantDigits how many digits to keep. The default is the only
65
+ * one that is purely artefact removal; a smaller number starts discarding real
66
+ * precision.
67
+ *
68
+ * @example
69
+ * toPrecision(5.1 + 0.1) // 5.2, rather than 5.199999999999999
70
+ */
71
+ function toPrecision(x, significantDigits = 15) {
72
+ if (x === 0 || !Number.isFinite(x)) return x;
73
+ const rounded = Number(x.toPrecision(significantDigits));
74
+ return Number.isFinite(rounded) ? rounded : x;
75
+ }
38
76
  function integerPart(x) {
39
77
  if (Number.isNaN(x)) return;
40
78
  return String(x).split(".")[0];
@@ -58,10 +96,98 @@ function gainToDb(gain) {
58
96
  return 20 * (Math.log(gain) / Math.LN10);
59
97
  }
60
98
  //#endregion
99
+ //#region src/modifiers.ts
100
+ /**
101
+ * Checked in this order, and the first one that is both held and configured
102
+ * wins. Fixing an order is what keeps two modifiers held at once from
103
+ * behaving differently between browsers.
104
+ */
105
+ const MODIFIER_ORDER = [
106
+ "meta",
107
+ "ctrl",
108
+ "alt",
109
+ "shift"
110
+ ];
111
+ const MODIFIER_FLAG = {
112
+ meta: "metaKey",
113
+ ctrl: "ctrlKey",
114
+ alt: "altKey",
115
+ shift: "shiftKey"
116
+ };
117
+ /**
118
+ * A map is the only form with a `default` key, which is what tells it apart
119
+ * from a bare setting. Tuples are arrays, so they never match.
120
+ */
121
+ function isModifierMap(value) {
122
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "default" in value;
123
+ }
124
+ /**
125
+ * Pick the setting that applies, given the modifier keys being held.
126
+ *
127
+ * @example
128
+ * selectModifier({ default: 1, shift: 0.1 }, event)
129
+ */
130
+ function selectModifier(options, modifiers) {
131
+ if (!isModifierMap(options)) return {
132
+ value: options,
133
+ modifier: null
134
+ };
135
+ if (modifiers) for (const modifier of MODIFIER_ORDER) {
136
+ const value = options[modifier];
137
+ if (value !== void 0 && modifiers[MODIFIER_FLAG[modifier]]) return {
138
+ value,
139
+ modifier
140
+ };
141
+ }
142
+ return {
143
+ value: options.default,
144
+ modifier: null
145
+ };
146
+ }
147
+ /**
148
+ * Turn every entry of a setting into another kind of setting, keeping which
149
+ * modifier each belongs to.
150
+ *
151
+ * A drag sensitivity is a number and a keyboard amount is a tuple, but the two
152
+ * describe the same thing from the caller's side. This carries one over to the
153
+ * other so that a component can hand a sensitivity to {@link applyDelta}
154
+ * without unpicking the modifier map itself — which matters, since naming a
155
+ * modifier is also what takes `step` out of the pipeline.
156
+ *
157
+ * @example
158
+ * mapModifier({ default: 1, shift: 0.1 }, (f) => ['raw', step * f])
159
+ * // { default: ['raw', 1], shift: ['raw', 0.1] }
160
+ */
161
+ function mapModifier(options, fn) {
162
+ if (!isModifierMap(options)) return fn(options);
163
+ const mapped = { default: fn(options.default) };
164
+ for (const modifier of MODIFIER_ORDER) {
165
+ const value = options[modifier];
166
+ if (value !== void 0) mapped[modifier] = fn(value);
167
+ }
168
+ return mapped;
169
+ }
170
+ /**
171
+ * Pick the amount that applies, given the modifier keys being held.
172
+ *
173
+ * @example
174
+ * selectInputEvent({ default: ['raw', 1], shift: ['raw', 0.1] }, event)
175
+ */
176
+ function selectInputEvent(options, modifiers) {
177
+ const { value, modifier } = selectModifier(options, modifiers);
178
+ return {
179
+ option: value,
180
+ modifier
181
+ };
182
+ }
183
+ //#endregion
61
184
  //#region src/scales.ts
62
185
  function assertRange(min, max) {
63
186
  if (min >= max) throw new RangeError("requirements: min < max");
64
187
  }
188
+ function assertPositiveFinite(value, name) {
189
+ if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name}: requirements: finite and greater than 0`);
190
+ }
65
191
  /**
66
192
  * Equal travel gives an equal change in value.
67
193
  *
@@ -89,6 +215,7 @@ const linearScale = {
89
215
  * @param skew the JUCE skew factor
90
216
  */
91
217
  function skewScale(skew) {
218
+ assertPositiveFinite(skew, "skewScale");
92
219
  return {
93
220
  normalize: (value, min, max) => Math.pow(normalizeValue(value, min, max), skew),
94
221
  denormalize: (position, min, max) => rawValue(skew === 1 ? position : Math.exp(Math.log(clamp(position, 0, 1)) / skew), min, max)
@@ -99,7 +226,8 @@ function skewScale(skew) {
99
226
  * of the travel — JUCE's `NormalisableRange::setSkewForCentre`.
100
227
  */
101
228
  function skewWithCenterValue(centerValue, min, max) {
102
- if (!(min <= centerValue && centerValue <= max)) throw new RangeError("requirements: min <= centerValue <= max");
229
+ assertRange(min, max);
230
+ if (!(min < centerValue && centerValue < max)) throw new RangeError("requirements: min < centerValue < max");
103
231
  return Math.log(.5) / Math.log((centerValue - min) / (max - min));
104
232
  }
105
233
  /**
@@ -116,11 +244,15 @@ function skewWithCenterValue(centerValue, min, max) {
116
244
  const exponentialScale = {
117
245
  normalize: (value, min, max) => {
118
246
  assertExponentialRange(min, max);
119
- return clamp(Math.log(clamp(value, min, max) / min) / Math.log(max / min), 0, 1);
247
+ const start = Math.log(Math.abs(min));
248
+ const end = Math.log(Math.abs(max));
249
+ return clamp((Math.log(Math.abs(clamp(value, min, max))) - start) / (end - start), 0, 1);
120
250
  },
121
251
  denormalize: (position, min, max) => {
122
252
  assertExponentialRange(min, max);
123
- return min * Math.pow(max / min, clamp(position, 0, 1));
253
+ const start = Math.log(Math.abs(min));
254
+ const magnitude = Math.exp(start + (Math.log(Math.abs(max)) - start) * clamp(position, 0, 1));
255
+ return Math.sign(min) * magnitude;
124
256
  }
125
257
  };
126
258
  function assertExponentialRange(min, max) {
@@ -149,25 +281,23 @@ function assertExponentialRange(min, max) {
149
281
  * @param curve how hard the curve bends, and in which direction
150
282
  */
151
283
  function curveScale(curve) {
284
+ if (!Number.isFinite(curve) || Math.abs(curve) > 32) throw new RangeError("curveScale: requirements: finite curve from -32 to 32");
152
285
  if (Math.abs(curve) < .001) return linearScale;
153
- const grow = Math.exp(curve);
154
- const coefficients = (min, max) => {
155
- const a = (max - min) / (1 - grow);
156
- return {
157
- a,
158
- b: min + a
159
- };
160
- };
161
286
  return {
162
287
  normalize: (value, min, max) => {
163
288
  assertRange(min, max);
164
- const { a, b } = coefficients(min, max);
165
- return clamp(Math.log((b - clamp(value, min, max)) / a) / curve, 0, 1);
289
+ const proportion = clamp((value - min) / (max - min), 0, 1);
290
+ if (proportion === 0 || proportion === 1) return proportion;
291
+ if (curve > 0) return 1 + Math.log(proportion + (1 - proportion) * Math.exp(-curve)) / curve;
292
+ return Math.log1p(proportion * Math.expm1(curve)) / curve;
166
293
  },
167
294
  denormalize: (position, min, max) => {
168
295
  assertRange(min, max);
169
- const { a, b } = coefficients(min, max);
170
- return b - a * Math.pow(grow, clamp(position, 0, 1));
296
+ const p = clamp(position, 0, 1);
297
+ if (p === 0) return min;
298
+ if (p === 1) return max;
299
+ const proportion = curve > 0 ? Math.exp(curve * (p - 1)) * (1 - Math.exp(-curve * p)) / (1 - Math.exp(-curve)) : Math.expm1(curve * p) / Math.expm1(curve);
300
+ return min + (max - min) * proportion;
171
301
  }
172
302
  };
173
303
  }
@@ -184,6 +314,7 @@ function curveScale(curve) {
184
314
  * @param skew the JUCE skew factor
185
315
  */
186
316
  function symmetricSkewScale(skew) {
317
+ assertPositiveFinite(skew, "symmetricSkewScale");
187
318
  return {
188
319
  normalize: (value, min, max) => {
189
320
  assertRange(min, max);
@@ -220,35 +351,33 @@ function curveWithCenterValue(centerValue, min, max) {
220
351
  * @param direction which way, and how many times, to apply the option. The
221
352
  * size of one step is `option[1]`, so this is normally `1` or `-1`.
222
353
  *
354
+ * @param modifiers the event, for `options` that name a modifier key. See
355
+ * {@link selectInputEvent}.
356
+ *
223
357
  * @example
224
358
  * // ArrowDown on a slider whose keyboard option is ['raw', 1]
225
359
  * applyDelta(value, -1, keyboard, { min, max, step, scale })
360
+ *
361
+ * @example
362
+ * // Shift+ArrowDown, where `keyboard` is { default: …, shift: ['raw', 0.1] }
363
+ * applyDelta(value, -1, keyboard, range, event)
226
364
  */
227
- function applyDelta(value, direction, [mode, amount], { min, max, step, scale = linearScale }) {
365
+ function applyDelta(value, direction, options, { min, max, step, scale = linearScale }, modifiers) {
366
+ assertRange(min, max);
367
+ if (step !== void 0) assertPositiveFinite(step, "applyDelta step");
368
+ const { option: [mode, amount], modifier } = selectInputEvent(options, modifiers);
228
369
  const x = direction * amount;
229
- const next = mode == "normalized" ? scale.denormalize(scale.normalize(value, min, max) + x, min, max) : value + x;
230
- return clamp(step ? stepValue(next, step) : next, min, max);
370
+ const next = mode === "normalized" ? scale.denormalize(scale.normalize(value, min, max) + x, min, max) : value + x;
371
+ const quantum = modifier === null ? step : void 0;
372
+ return clamp(toPrecision(quantum !== void 0 ? stepValue(next, quantum) : next), min, max);
231
373
  }
232
374
  //#endregion
233
375
  //#region src/util.ts
234
- function styleHelper(value, op, influencer) {
235
- if (op && influencer) if (typeof value == "number") {
236
- if (op == "+") return `${value + influencer}px`;
237
- if (op == "-") return `${value - influencer}px`;
238
- if (op == "*") return `${value * influencer}px`;
239
- if (op == "/") return `${value / influencer}px`;
240
- } else return `calc(${value}px ${op} ${influencer})`;
241
- else if (typeof value == "number") return `${value}px`;
242
- else return value;
243
- }
244
- function isEmpty(obj) {
245
- return Object.keys(obj).length == 0;
246
- }
247
376
  function mod(n, m) {
248
377
  return (n % m + m) % m;
249
378
  }
250
379
  function xor(a = false, b = false) {
251
- return (a || b) && a != b;
380
+ return (a || b) && a !== b;
252
381
  }
253
382
  //#endregion
254
383
  //#region src/midi.ts
@@ -275,14 +404,19 @@ const noteKeys = [
275
404
  "A#",
276
405
  "B"
277
406
  ];
407
+ function assertSafeInteger(value, name) {
408
+ if (!Number.isSafeInteger(value)) throw new RangeError(`${name}: requirements: a safe integer`);
409
+ }
278
410
  function parseNoteName(noteName) {
279
411
  const m = noteName.match(/^([a-g])(#{0,2}|b{0,2})(-?\d+)$/i);
280
412
  if (!m) throw new Error("Invalid note name");
281
413
  const [, letter, accidental, octave] = m;
414
+ const parsedOctave = Number(octave);
415
+ assertSafeInteger(parsedOctave, "octave");
282
416
  return {
283
417
  letter: letter.toLocaleUpperCase(),
284
418
  accidental,
285
- octave: Number(octave)
419
+ octave: parsedOctave
286
420
  };
287
421
  }
288
422
  /**
@@ -291,8 +425,10 @@ function parseNoteName(noteName) {
291
425
  function noteNumber(noteName) {
292
426
  const { letter, accidental, octave } = parseNoteName(noteName);
293
427
  const noteIndex = noteKeys.indexOf(letter.toLocaleUpperCase());
294
- const accidentalValue = (accidental[0] == "b" ? -1 : 1) * accidental.length;
295
- return noteIndex + 12 * (Number(octave) + 1) + accidentalValue;
428
+ const accidentalValue = (accidental[0] === "b" ? -1 : 1) * accidental.length;
429
+ const result = noteIndex + 12 * (octave + 1) + accidentalValue;
430
+ assertSafeInteger(result, "note number");
431
+ return result;
296
432
  }
297
433
  /**
298
434
  * Convert noteNumber to noteName
@@ -301,6 +437,7 @@ function noteNumber(noteName) {
301
437
  * @param noteNumber noteNumber
302
438
  */
303
439
  function noteName(noteNumber) {
440
+ assertSafeInteger(noteNumber, "note number");
304
441
  const noteIndex = mod(noteNumber, 12);
305
442
  const octave = Math.floor(noteNumber / 12) - 1;
306
443
  return `${noteKeys[noteIndex]}${octave}`;
@@ -309,14 +446,16 @@ function noteName(noteNumber) {
309
446
  * Convert noteNumber to noteKey
310
447
  */
311
448
  function noteKey(noteNumber) {
449
+ assertSafeInteger(noteNumber, "note number");
312
450
  return noteKeys[mod(noteNumber, 12)];
313
451
  }
314
452
  /**
315
453
  * @param note noteNumber: 0 ~ 127 or noteName e.g. 'C3'
316
454
  */
317
455
  function isWhiteKey(note) {
318
- const n = typeof note == "string" ? noteNumber(note) : note;
319
- return mod(n, 12) == 0 || mod(n, 12) == 2 || mod(n, 12) == 4 || mod(n, 12) == 5 || mod(n, 12) == 7 || mod(n, 12) == 9 || mod(n, 12) == 11;
456
+ const n = typeof note === "string" ? noteNumber(note) : note;
457
+ assertSafeInteger(n, "note number");
458
+ return mod(n, 12) === 0 || mod(n, 12) === 2 || mod(n, 12) === 4 || mod(n, 12) === 5 || mod(n, 12) === 7 || mod(n, 12) === 9 || mod(n, 12) === 11;
320
459
  }
321
460
  /**
322
461
  * @param note noteNumber: 0 ~ 127 or noteName e.g. 'C3'
@@ -331,7 +470,8 @@ function isBlackKey(note) {
331
470
  * @returns frequency [Hz]
332
471
  */
333
472
  function noteToFrequency(note, detune = 0, a4 = 440) {
334
- const n = typeof note == "string" ? noteNumber(note) : note;
473
+ const n = typeof note === "string" ? noteNumber(note) : note;
474
+ assertSafeInteger(n, "note number");
335
475
  return a4 / 32 * 2 ** ((n - 9 + detune / 100) / 12);
336
476
  }
337
477
  /**
@@ -502,8 +642,10 @@ const scaleIntervals = {
502
642
  * ```
503
643
  */
504
644
  function inScale(note, root, name) {
505
- const n = typeof note == "string" ? noteNumber(note) : note;
506
- const r = typeof root == "string" ? noteNumber(root) : root;
645
+ const n = typeof note === "string" ? noteNumber(note) : note;
646
+ const r = typeof root === "string" ? noteNumber(root) : root;
647
+ assertSafeInteger(n, "note number");
648
+ assertSafeInteger(r, "root note number");
507
649
  return scaleIntervals[name].includes(mod(n - r, 12));
508
650
  }
509
651
  /**
@@ -521,7 +663,9 @@ function inScale(note, root, name) {
521
663
  * ```
522
664
  */
523
665
  function scaleNotes(root, name, octaves = 1) {
524
- const r = typeof root == "string" ? noteNumber(root) : root;
666
+ const r = typeof root === "string" ? noteNumber(root) : root;
667
+ assertSafeInteger(r, "root note number");
668
+ if (!Number.isSafeInteger(octaves) || octaves < 0) throw new RangeError("octaves: requirements: a non-negative safe integer");
525
669
  const intervals = scaleIntervals[name];
526
670
  return Array.from({ length: octaves }, (_, octave) => intervals.map((interval) => r + octave * 12 + interval)).flat();
527
671
  }
@@ -561,10 +705,39 @@ const whiteKeysBefore = {
561
705
  function blackKeyWidth(layout) {
562
706
  return layout.whiteKeyWidth * (layout.blackKeyWidthRatio ?? DEFAULT_BLACK_KEY_WIDTH_RATIO);
563
707
  }
708
+ function rawNotePosition(note, layout) {
709
+ const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
710
+ const target = noteKey(note);
711
+ const first = noteKey(layout.noteRange.first);
712
+ const octave = Math.floor((note - layout.noteRange.first) / 12);
713
+ const octaveOffset = noteKeys.indexOf(first) > noteKeys.indexOf(target) ? 1 : 0;
714
+ const whiteKeysIn = whiteKeysBefore[target] - whiteKeysBefore[first] + (octave + octaveOffset) * 7;
715
+ return isBlackKey(note) ? whiteKeysIn * slot - blackKeyWidth(layout) / 2 : whiteKeysIn * slot;
716
+ }
717
+ function pianoBounds(layout) {
718
+ const notes = getNoteRangeArray(layout.noteRange);
719
+ if (notes.length === 0) return {
720
+ left: 0,
721
+ right: 0
722
+ };
723
+ let left = Infinity;
724
+ let right = -Infinity;
725
+ const whiteWidth = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
726
+ for (const note of notes) {
727
+ const noteLeft = rawNotePosition(note, layout);
728
+ const width = isBlackKey(note) ? blackKeyWidth(layout) : whiteWidth;
729
+ left = Math.min(left, noteLeft);
730
+ right = Math.max(right, noteLeft + width);
731
+ }
732
+ return {
733
+ left,
734
+ right
735
+ };
736
+ }
564
737
  /** Width of the whole keyboard in pixels. */
565
738
  function pianoWidth(layout) {
566
- const whiteKeys = getNoteRangeArray(layout.noteRange).filter(isWhiteKey);
567
- return (layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)) * whiteKeys.length;
739
+ const { left, right } = pianoBounds(layout);
740
+ return right - left;
568
741
  }
569
742
  /**
570
743
  * Offset of the left edge of a key from the left edge of the keyboard, in
@@ -574,13 +747,7 @@ function pianoWidth(layout) {
574
747
  * `noteRange.first`.
575
748
  */
576
749
  function notePosition(note, layout) {
577
- const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP);
578
- const target = noteKey(note);
579
- const first = noteKey(layout.noteRange.first);
580
- const octave = Math.floor((note - layout.noteRange.first) / 12);
581
- const octaveOffset = noteKeys.indexOf(first) > noteKeys.indexOf(target) ? 1 : 0;
582
- const whiteKeysIn = whiteKeysBefore[target] - whiteKeysBefore[first] + (octave + octaveOffset) * 7;
583
- return isBlackKey(note) ? whiteKeysIn * slot - blackKeyWidth(layout) / 2 : whiteKeysIn * slot;
750
+ return rawNotePosition(note, layout) - pianoBounds(layout).left;
584
751
  }
585
752
  /**
586
753
  * The note drawn at a point, or null where there is none.
@@ -594,7 +761,7 @@ function notePosition(note, layout) {
594
761
  * @param height height of the keyboard, in pixels
595
762
  */
596
763
  function noteAt(x, y, height, layout) {
597
- if (y < 0 || y >= height) return null;
764
+ if (x < 0 || x >= pianoWidth(layout) || y < 0 || y >= height) return null;
598
765
  const notes = getNoteRangeArray(layout.noteRange);
599
766
  if (y < height * (layout.blackKeyHeightRatio ?? DEFAULT_BLACK_KEY_HEIGHT_RATIO)) for (const note of notes) {
600
767
  if (isWhiteKey(note)) continue;
@@ -611,56 +778,120 @@ function noteAt(x, y, height, layout) {
611
778
  }
612
779
  //#endregion
613
780
  //#region src/unit.ts
781
+ /** Ordered small to large. The empty symbol is the base unit. */
782
+ const PREFIXES = [
783
+ ["p", 1e-12],
784
+ ["n", 1e-9],
785
+ ["µ", 1e-6],
786
+ ["m", .001],
787
+ ["", 1],
788
+ ["k", 1e3],
789
+ ["M", 1e6],
790
+ ["G", 1e9]
791
+ ];
792
+ const PREFIX_SCALE = new Map(PREFIXES);
614
793
  /**
615
- * Pick the unit a value is displayed in: the largest one whose scale does not
616
- * exceed the magnitude of the value.
794
+ * Micro is written three ways. `µ` (U+00B5 MICRO SIGN) is what `format`
795
+ * writes and what d3-format uses, `μ` (U+03BC GREEK SMALL LETTER MU) looks
796
+ * identical and is what a Greek keyboard produces, and `u` is what everyone
797
+ * actually types. All three read back the same.
617
798
  */
618
- function selectUnit(units, value) {
619
- let i = 0;
620
- for (; i < units.length; i++) if (Math.abs(units[i][1]) > Math.abs(value)) break;
621
- return units[Math.max(0, i - 1)];
622
- }
799
+ const MICRO_ALIASES = {
800
+ μ: "µ",
801
+ u: "µ"
802
+ };
623
803
  /**
624
- * Render a value as text, in the unit that suits its magnitude.
625
- *
626
- * @param units a single symbol appended as-is, or a list to choose from.
627
- * @param digit digits after the decimal point. Left as-is when omitted.
804
+ * Divide by a prefix scale without showing the result of doing so in binary.
628
805
  *
629
- * @example
630
- * formatValue(1234, [['Hz', 1], ['kHz', 1000]], 2) // '1.23kHz'
631
- * formatValue(1.5, 'Hz') // '1.5Hz'
806
+ * `0.0005 / 1e-6` is 500.00000000000006, and with no `digits` to round it that
807
+ * lands in the input as written.
632
808
  */
633
- function formatValue(value, units, digit) {
634
- const fixed = (v) => digit != void 0 ? v.toFixed(digit) : String(v);
635
- if (!units || typeof units == "string") return fixed(value) + (units ?? "");
636
- const [unit, scale] = selectUnit(units, value);
637
- return fixed(value / scale) + unit;
809
+ function scaleBy(value, scale) {
810
+ return toPrecision(value / scale);
638
811
  }
812
+ /** A number, then whatever followed it. */
813
+ const NUMBER_THEN_REST = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(.*)$/;
639
814
  /**
640
- * Read a value back out of text, undoing the scaling of {@link formatValue}.
815
+ * Build the `format` and `parse` of a unit, as one pair.
816
+ *
817
+ * They are returned together because they have to agree: a `format` that
818
+ * writes `1.23kHz` is only useful next to a `parse` that reads it back as
819
+ * 1230. Spread the result into the input.
641
820
  *
642
- * With a list of units the text has to be a number followed by an optional
643
- * unit and nothing else, since the unit decides the scale; anything else reads
644
- * as 0. With a single unit, or none, the first number found anywhere in the
645
- * text is taken, so a half-typed entry still yields something.
821
+ * @example
822
+ * unitFormat('Hz') // 1234 -> '1.23kHz'
823
+ * unitFormat('s', { base: 'm' }) // value in ms. 1500 -> '1.5s'
824
+ * unitFormat('s', { base: 'm', digits: 2 }) // 1500 -> '1.50s'
825
+ * unitFormat('dB', { prefixes: false, digits: 1 }) // -6.25 -> '-6.3dB'
646
826
  *
647
827
  * @example
648
- * parseValue('1.23kHz', [['Hz', 1], ['kHz', 1000]]) // 1230
649
- * parseValue('4abc') // 4
650
- */
651
- function parseValue(text, units) {
652
- const str = text.trim();
653
- if (!units || typeof units == "string") {
654
- const m = str.match(/-?\d+(\.\d+)?/);
655
- const v = Number(m?.[0] ?? "0");
656
- return isNaN(v) ? 0 : v;
828
+ * <NumberInput.Root {...unitFormat('Hz', { digits: 2 })} value={v} onChange={setV}>
829
+ */
830
+ function unitFormat(unit, options = {}) {
831
+ const { base = "", prefixes = true, digits, separator = "" } = options;
832
+ if (unit === "" && base !== "") throw new RangeError("unitFormat: base requires a non-empty unit");
833
+ const baseScale = PREFIX_SCALE.get(base) ?? 1;
834
+ /**
835
+ * `toFixed` renders anything that rounds to zero from below as `-0`, which
836
+ * is never what a control should show.
837
+ */
838
+ const fixed = (value) => {
839
+ const text = digits !== void 0 ? value.toFixed(digits) : String(value);
840
+ return Number(text) === 0 ? text.replace("-", "") : text;
841
+ };
842
+ if (!prefixes) {
843
+ const symbol = base + unit;
844
+ return {
845
+ format: (value) => Number.isFinite(value) ? fixed(value) + separator + symbol : String(value),
846
+ parse: (text) => {
847
+ const match = text.trim().match(NUMBER_THEN_REST);
848
+ if (!match) return NaN;
849
+ const value = Number(match[1]);
850
+ return Number.isFinite(value) ? value : NaN;
851
+ }
852
+ };
657
853
  }
658
- const m = str.match(/^(-?\d+(\.\d+)?)\s*(\w*)$/);
659
- if (!m) return 0;
660
- const found = units.find(([unit]) => unit == m[3]);
661
- return (Number(m[1]) || 0) * (found ? found[1] : 1);
854
+ return {
855
+ format: (value) => {
856
+ if (!Number.isFinite(value)) return String(value);
857
+ const si = value * baseScale;
858
+ let index = PREFIXES.findIndex(([, scale]) => scale === 1);
859
+ if (si !== 0) {
860
+ const magnitude = Math.abs(si);
861
+ index = 0;
862
+ for (let i = PREFIXES.length - 1; i >= 0; i--) if (magnitude >= PREFIXES[i][1]) {
863
+ index = i;
864
+ break;
865
+ }
866
+ }
867
+ let text = fixed(scaleBy(si, PREFIXES[index][1]));
868
+ if (Math.abs(Number(text)) >= 1e3 && index < PREFIXES.length - 1) {
869
+ index += 1;
870
+ text = fixed(scaleBy(si, PREFIXES[index][1]));
871
+ }
872
+ return text + separator + PREFIXES[index][0] + unit;
873
+ },
874
+ parse: (text) => {
875
+ const match = text.trim().match(NUMBER_THEN_REST);
876
+ if (!match) return NaN;
877
+ const number = Number(match[1]);
878
+ if (!Number.isFinite(number)) return NaN;
879
+ let suffix = match[2].trim();
880
+ const separatorText = separator.trim();
881
+ if (separatorText !== "" && suffix.startsWith(separatorText)) suffix = suffix.slice(separatorText.length).trim();
882
+ if (suffix === "") return number;
883
+ let prefix = null;
884
+ if (unit !== "" && suffix.endsWith(unit)) prefix = suffix.slice(0, suffix.length - unit.length);
885
+ else if (suffix.length <= 1) prefix = suffix;
886
+ if (prefix === null) return number;
887
+ const normalized = MICRO_ALIASES[prefix] ?? prefix;
888
+ const scale = PREFIX_SCALE.get(normalized);
889
+ if (scale === void 0) return number;
890
+ return number * scale / baseScale;
891
+ }
892
+ };
662
893
  }
663
894
  //#endregion
664
- export { applyDelta, blackKeyWidth, clamp, curveScale, curveWithCenterValue, dbToGain, decimalPart, degree, exponentialScale, formatValue, gainToDb, getNoteRangeArray, inScale, integerPart, isBlackKey, isEmpty, isWhiteKey, linearScale, mapValue, mod, normalizeValue, noteAt, noteKey, noteKeys, noteName, noteNumber, notePosition, noteToFrequency, parseNoteName, parseValue, pianoWidth, radian, rawValue, scaleIntervals, scaleNotes, selectUnit, skewScale, skewWithCenterValue, stepValue, styleHelper, symmetricSkewScale, toFixed, whiteKeys, xor };
895
+ export { SIGNIFICANT_DIGITS, applyDelta, blackKeyWidth, clamp, curveScale, curveWithCenterValue, dbToGain, decimalPart, degree, exponentialScale, gainToDb, getNoteRangeArray, inScale, integerPart, isBlackKey, isWhiteKey, linearScale, mapModifier, mapValue, mod, normalizeValue, noteAt, noteKey, noteKeys, noteName, noteNumber, notePosition, noteToFrequency, parseNoteName, pianoWidth, radian, rawValue, scaleIntervals, scaleNotes, selectInputEvent, selectModifier, skewScale, skewWithCenterValue, stepValue, symmetricSkewScale, toFixed, toPrecision, unitFormat, whiteKeys, xor };
665
896
 
666
897
  //# sourceMappingURL=index.js.map