@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.
package/src/unit.ts ADDED
@@ -0,0 +1,204 @@
1
+ import { toPrecision } from './math'
2
+
3
+ /**
4
+ * The SI prefixes {@link unitFormat} chooses between.
5
+ *
6
+ * Deliberately narrower than the full SI set: yocto through yotta are of no
7
+ * use to an audio control, and every extra prefix is one more symbol `parse`
8
+ * has to tell apart from a unit.
9
+ */
10
+ export type SIPrefix = 'p' | 'n' | 'µ' | 'm' | '' | 'k' | 'M' | 'G'
11
+
12
+ /** Ordered small to large. The empty symbol is the base unit. */
13
+ const PREFIXES: readonly [SIPrefix, number][] = [
14
+ ['p', 1e-12],
15
+ ['n', 1e-9],
16
+ ['µ', 1e-6],
17
+ ['m', 1e-3],
18
+ ['', 1],
19
+ ['k', 1e3],
20
+ ['M', 1e6],
21
+ ['G', 1e9],
22
+ ]
23
+
24
+ const PREFIX_SCALE = new Map<string, number>(PREFIXES)
25
+
26
+ /**
27
+ * Micro is written three ways. `µ` (U+00B5 MICRO SIGN) is what `format`
28
+ * writes and what d3-format uses, `μ` (U+03BC GREEK SMALL LETTER MU) looks
29
+ * identical and is what a Greek keyboard produces, and `u` is what everyone
30
+ * actually types. All three read back the same.
31
+ */
32
+ const MICRO_ALIASES: Record<string, SIPrefix> = { μ: 'µ', u: 'µ' }
33
+
34
+ export interface UnitFormatOptions {
35
+ /**
36
+ * The prefix the stored value is already in.
37
+ *
38
+ * A control that keeps milliseconds in `value` is `{ base: 'm' }` with a
39
+ * unit of `'s'`: 1500 then displays as `1.5s`, and `parse` gives 1500 back.
40
+ *
41
+ * @default ''
42
+ */
43
+ base?: SIPrefix
44
+ /**
45
+ * Whether to scale the number and pick a prefix at all.
46
+ *
47
+ * Turn it off for anything that is not an SI quantity. dB, %, cents and
48
+ * semitones do not take prefixes, and `-6dB` read as "-6 deci-B" is wrong
49
+ * rather than merely unusual.
50
+ *
51
+ * @default true
52
+ */
53
+ prefixes?: boolean
54
+ /**
55
+ * Digits after the decimal point. The number is left as-is when omitted.
56
+ */
57
+ digits?: number
58
+ /**
59
+ * Text placed between the number and the unit.
60
+ * @default ''
61
+ */
62
+ separator?: string
63
+ }
64
+
65
+ /** The `format` / `parse` pair a `NumberInput` takes. */
66
+ export interface UnitFormatter {
67
+ format: (value: number) => string
68
+ parse: (text: string) => number
69
+ }
70
+
71
+ /**
72
+ * Divide by a prefix scale without showing the result of doing so in binary.
73
+ *
74
+ * `0.0005 / 1e-6` is 500.00000000000006, and with no `digits` to round it that
75
+ * lands in the input as written.
76
+ */
77
+ function scaleBy(value: number, scale: number): number {
78
+ return toPrecision(value / scale)
79
+ }
80
+
81
+ /** A number, then whatever followed it. */
82
+ const NUMBER_THEN_REST =
83
+ /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(.*)$/
84
+
85
+ /**
86
+ * Build the `format` and `parse` of a unit, as one pair.
87
+ *
88
+ * They are returned together because they have to agree: a `format` that
89
+ * writes `1.23kHz` is only useful next to a `parse` that reads it back as
90
+ * 1230. Spread the result into the input.
91
+ *
92
+ * @example
93
+ * unitFormat('Hz') // 1234 -> '1.23kHz'
94
+ * unitFormat('s', { base: 'm' }) // value in ms. 1500 -> '1.5s'
95
+ * unitFormat('s', { base: 'm', digits: 2 }) // 1500 -> '1.50s'
96
+ * unitFormat('dB', { prefixes: false, digits: 1 }) // -6.25 -> '-6.3dB'
97
+ *
98
+ * @example
99
+ * <NumberInput.Root {...unitFormat('Hz', { digits: 2 })} value={v} onChange={setV}>
100
+ */
101
+ export function unitFormat(
102
+ unit: string,
103
+ options: UnitFormatOptions = {},
104
+ ): UnitFormatter {
105
+ const { base = '', prefixes = true, digits, separator = '' } = options
106
+ if (unit === '' && base !== '') {
107
+ throw new RangeError('unitFormat: base requires a non-empty unit')
108
+ }
109
+ const baseScale = PREFIX_SCALE.get(base) ?? 1
110
+
111
+ /**
112
+ * `toFixed` renders anything that rounds to zero from below as `-0`, which
113
+ * is never what a control should show.
114
+ */
115
+ const fixed = (value: number) => {
116
+ const text = digits !== undefined ? value.toFixed(digits) : String(value)
117
+ return Number(text) === 0 ? text.replace('-', '') : text
118
+ }
119
+
120
+ if (!prefixes) {
121
+ // The stored value goes out untouched, so the symbol has to name the unit
122
+ // it is already in.
123
+ const symbol = base + unit
124
+ return {
125
+ format: (value) =>
126
+ Number.isFinite(value)
127
+ ? fixed(value) + separator + symbol
128
+ : String(value),
129
+ // Nothing after the number can change the scale, so it is all ignored:
130
+ // the number in front is the value, half-typed or not.
131
+ parse: (text) => {
132
+ const match = text.trim().match(NUMBER_THEN_REST)
133
+ if (!match) return NaN
134
+ const value = Number(match[1])
135
+ return Number.isFinite(value) ? value : NaN
136
+ },
137
+ }
138
+ }
139
+
140
+ return {
141
+ format: (value) => {
142
+ if (!Number.isFinite(value)) return String(value)
143
+ const si = value * baseScale
144
+ // Zero has no magnitude to read, so it stays in the base unit.
145
+ let index = PREFIXES.findIndex(([, scale]) => scale === 1)
146
+ if (si !== 0) {
147
+ // The largest prefix that leaves at least one digit before the point.
148
+ // Below the smallest prefix the number just gets small: `p` is the
149
+ // floor, as `G` is the ceiling.
150
+ const magnitude = Math.abs(si)
151
+ index = 0
152
+ for (let i = PREFIXES.length - 1; i >= 0; i--) {
153
+ if (magnitude >= PREFIXES[i][1]) {
154
+ index = i
155
+ break
156
+ }
157
+ }
158
+ }
159
+ let text = fixed(scaleBy(si, PREFIXES[index][1]))
160
+ // Rounding can carry the number up out of its own prefix — 999.99Hz at
161
+ // one digit is 1000.0Hz, which should read 1.0kHz. One step is always
162
+ // enough, since the carry is at most a factor of ten.
163
+ if (Math.abs(Number(text)) >= 1000 && index < PREFIXES.length - 1) {
164
+ index += 1
165
+ text = fixed(scaleBy(si, PREFIXES[index][1]))
166
+ }
167
+ return text + separator + PREFIXES[index][0] + unit
168
+ },
169
+
170
+ parse: (text) => {
171
+ const match = text.trim().match(NUMBER_THEN_REST)
172
+ if (!match) return NaN
173
+ const number = Number(match[1])
174
+ if (!Number.isFinite(number)) return NaN
175
+
176
+ let suffix = match[2].trim()
177
+ const separatorText = separator.trim()
178
+ if (separatorText !== '' && suffix.startsWith(separatorText)) {
179
+ suffix = suffix.slice(separatorText.length).trim()
180
+ }
181
+ // A bare number is in the unit the value is stored in, which is what
182
+ // the input shows once the format is stripped.
183
+ if (suffix === '') return number
184
+
185
+ // The unit symbol is matched first, so a unit that is itself a prefix
186
+ // letter wins over the prefix reading: `5m` for a unit of `m` is five
187
+ // metres, not five milli-.
188
+ let prefix: string | null = null
189
+ if (unit !== '' && suffix.endsWith(unit)) {
190
+ prefix = suffix.slice(0, suffix.length - unit.length)
191
+ } else if (suffix.length <= 1) {
192
+ prefix = suffix
193
+ }
194
+ if (prefix === null) return number
195
+
196
+ const normalized = MICRO_ALIASES[prefix] ?? prefix
197
+ const scale = PREFIX_SCALE.get(normalized)
198
+ // Unrecognised text after the number is ignored rather than rejected,
199
+ // so that a half-typed entry still yields the number in front of it.
200
+ if (scale === undefined) return number
201
+ return (number * scale) / baseScale
202
+ },
203
+ }
204
+ }
package/src/util.ts CHANGED
@@ -1,42 +1,7 @@
1
- type Operator = '+' | '-' | '*' | '/'
2
-
3
- export function styleHelper(value: string | number): string
4
- export function styleHelper(
5
- value: string | number,
6
- op: Operator,
7
- influencer?: number,
8
- ): string
9
- export function styleHelper(
10
- value: string | number,
11
- op?: Operator,
12
- influencer?: number,
13
- ) {
14
- if (op && influencer) {
15
- if (typeof value == 'number') {
16
- if (op == '+') return `${value + influencer}px`
17
- if (op == '-') return `${value - influencer}px`
18
- if (op == '*') return `${value * influencer}px`
19
- if (op == '/') return `${value / influencer}px`
20
- } else {
21
- return `calc(${value}px ${op} ${influencer})`
22
- }
23
- } else {
24
- if (typeof value == 'number') {
25
- return `${value}px`
26
- } else {
27
- return value
28
- }
29
- }
30
- }
31
-
32
- export function isEmpty(obj: object) {
33
- return Object.keys(obj).length == 0
34
- }
35
-
36
1
  export function mod(n: number, m: number) {
37
2
  return ((n % m) + m) % m
38
3
  }
39
4
 
40
5
  export function xor(a = false, b = false) {
41
- return (a || b) && a != b
6
+ return (a || b) && a !== b
42
7
  }
package/src/types.ts DELETED
@@ -1,4 +0,0 @@
1
- /**
2
- * Options for setting the amount of keyboard and mouse wheel changes.
3
- */
4
- export type InputEventOption = ['normalized' | 'raw', number]