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