@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 +330 -98
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +163 -42
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +163 -42
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +325 -94
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +20 -3
- package/src/math.ts +53 -5
- package/src/midi.ts +35 -15
- package/src/modifiers.ts +141 -0
- package/src/piano.ts +35 -21
- package/src/scales.ts +79 -25
- package/src/unit.ts +182 -51
- package/src/util.ts +1 -36
- package/src/types.ts +0 -4
package/src/piano.ts
CHANGED
|
@@ -84,33 +84,14 @@ export function blackKeyWidth(layout: PianoLayout): number {
|
|
|
84
84
|
)
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
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 {
|
|
87
|
+
function rawNotePosition(note: number, layout: PianoLayout): number {
|
|
104
88
|
const slot = layout.whiteKeyWidth + (layout.keyGap ?? DEFAULT_KEY_GAP)
|
|
105
89
|
const target = noteKey(note)
|
|
106
90
|
const first = noteKey(layout.noteRange.first)
|
|
107
91
|
|
|
108
92
|
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
93
|
const octaveOffset =
|
|
112
94
|
noteKeys.indexOf(first) > noteKeys.indexOf(target) ? 1 : 0
|
|
113
|
-
|
|
114
95
|
const whiteKeysIn =
|
|
115
96
|
whiteKeysBefore[target] -
|
|
116
97
|
whiteKeysBefore[first] +
|
|
@@ -121,6 +102,39 @@ export function notePosition(note: number, layout: PianoLayout): number {
|
|
|
121
102
|
: whiteKeysIn * slot
|
|
122
103
|
}
|
|
123
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
|
+
|
|
124
138
|
/**
|
|
125
139
|
* The note drawn at a point, or null where there is none.
|
|
126
140
|
*
|
|
@@ -138,7 +152,7 @@ export function noteAt(
|
|
|
138
152
|
height: number,
|
|
139
153
|
layout: PianoLayout,
|
|
140
154
|
): number | null {
|
|
141
|
-
if (y < 0 || y >= height) return null
|
|
155
|
+
if (x < 0 || x >= pianoWidth(layout) || y < 0 || y >= height) return null
|
|
142
156
|
|
|
143
157
|
const notes = getNoteRangeArray(layout.noteRange)
|
|
144
158
|
const blackHeight =
|
package/src/scales.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { clamp, normalizeValue, rawValue, stepValue } from './math'
|
|
2
|
-
|
|
3
|
-
|
|
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'
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* How a value is distributed across the travel of a control.
|
|
@@ -28,6 +32,12 @@ function assertRange(min: number, max: number) {
|
|
|
28
32
|
if (min >= max) throw new RangeError('requirements: min < max')
|
|
29
33
|
}
|
|
30
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
|
+
|
|
31
41
|
/**
|
|
32
42
|
* Equal travel gives an equal change in value.
|
|
33
43
|
*
|
|
@@ -56,6 +66,7 @@ export const linearScale: Scale = {
|
|
|
56
66
|
* @param skew the JUCE skew factor
|
|
57
67
|
*/
|
|
58
68
|
export function skewScale(skew: number): Scale {
|
|
69
|
+
assertPositiveFinite(skew, 'skewScale')
|
|
59
70
|
return {
|
|
60
71
|
// The two expressions JUCE uses, kept verbatim so the numbers agree with
|
|
61
72
|
// a NormalisableRange: pow() one way, exp(log()) the other.
|
|
@@ -81,8 +92,9 @@ export function skewWithCenterValue(
|
|
|
81
92
|
min: number,
|
|
82
93
|
max: number,
|
|
83
94
|
) {
|
|
84
|
-
|
|
85
|
-
|
|
95
|
+
assertRange(min, max)
|
|
96
|
+
if (!(min < centerValue && centerValue < max))
|
|
97
|
+
throw new RangeError('requirements: min < centerValue < max')
|
|
86
98
|
return Math.log(0.5) / Math.log((centerValue - min) / (max - min))
|
|
87
99
|
}
|
|
88
100
|
|
|
@@ -103,15 +115,20 @@ export const exponentialScale: Scale = {
|
|
|
103
115
|
// The value is clamped before the logarithm, not after: outside the range
|
|
104
116
|
// the ratio can be negative, and log() would give NaN rather than a
|
|
105
117
|
// position to clamp.
|
|
118
|
+
const start = Math.log(Math.abs(min))
|
|
119
|
+
const end = Math.log(Math.abs(max))
|
|
106
120
|
return clamp(
|
|
107
|
-
Math.log(clamp(value, min, max)
|
|
121
|
+
(Math.log(Math.abs(clamp(value, min, max))) - start) / (end - start),
|
|
108
122
|
0,
|
|
109
123
|
1,
|
|
110
124
|
)
|
|
111
125
|
},
|
|
112
126
|
denormalize: (position, min, max) => {
|
|
113
127
|
assertExponentialRange(min, max)
|
|
114
|
-
|
|
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
|
|
115
132
|
},
|
|
116
133
|
}
|
|
117
134
|
|
|
@@ -146,31 +163,40 @@ function assertExponentialRange(min: number, max: number) {
|
|
|
146
163
|
* @param curve how hard the curve bends, and in which direction
|
|
147
164
|
*/
|
|
148
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
|
+
}
|
|
149
173
|
// The two coefficients blow up as the curve flattens: `a` divides by
|
|
150
174
|
// 1 - e^curve, which goes to 0.
|
|
151
175
|
if (Math.abs(curve) < 0.001) return linearScale
|
|
152
176
|
|
|
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
177
|
return {
|
|
163
178
|
normalize: (value, min, max) => {
|
|
164
179
|
assertRange(min, max)
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
169
188
|
},
|
|
170
189
|
denormalize: (position, min, max) => {
|
|
171
190
|
assertRange(min, max)
|
|
172
|
-
const
|
|
173
|
-
|
|
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
|
|
174
200
|
},
|
|
175
201
|
}
|
|
176
202
|
}
|
|
@@ -188,6 +214,7 @@ export function curveScale(curve: number): Scale {
|
|
|
188
214
|
* @param skew the JUCE skew factor
|
|
189
215
|
*/
|
|
190
216
|
export function symmetricSkewScale(skew: number): Scale {
|
|
217
|
+
assertPositiveFinite(skew, 'symmetricSkewScale')
|
|
191
218
|
return {
|
|
192
219
|
normalize: (value, min, max) => {
|
|
193
220
|
assertRange(min, max)
|
|
@@ -265,20 +292,47 @@ export interface ValueRange {
|
|
|
265
292
|
* @param direction which way, and how many times, to apply the option. The
|
|
266
293
|
* size of one step is `option[1]`, so this is normally `1` or `-1`.
|
|
267
294
|
*
|
|
295
|
+
* @param modifiers the event, for `options` that name a modifier key. See
|
|
296
|
+
* {@link selectInputEvent}.
|
|
297
|
+
*
|
|
268
298
|
* @example
|
|
269
299
|
* // ArrowDown on a slider whose keyboard option is ['raw', 1]
|
|
270
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)
|
|
271
305
|
*/
|
|
272
306
|
export function applyDelta(
|
|
273
307
|
value: number,
|
|
274
308
|
direction: number,
|
|
275
|
-
|
|
309
|
+
options: ModifierValue<InputEventOption>,
|
|
276
310
|
{ min, max, step, scale = linearScale }: ValueRange,
|
|
311
|
+
modifiers?: ModifierState,
|
|
277
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
|
+
|
|
278
321
|
const x = direction * amount
|
|
279
322
|
const next =
|
|
280
|
-
mode
|
|
323
|
+
mode === 'normalized'
|
|
281
324
|
? scale.denormalize(scale.normalize(value, min, max) + x, min, max)
|
|
282
325
|
: value + x
|
|
283
|
-
|
|
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)
|
|
284
338
|
}
|
package/src/unit.ts
CHANGED
|
@@ -1,73 +1,204 @@
|
|
|
1
|
+
import { toPrecision } from './math'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
3
|
-
* the smallest scale up. A value is shown in the largest unit that does not
|
|
4
|
-
* exceed it.
|
|
4
|
+
* The SI prefixes {@link unitFormat} chooses between.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
9
|
*/
|
|
10
|
-
export type
|
|
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)
|
|
11
25
|
|
|
12
26
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
15
31
|
*/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
22
69
|
}
|
|
23
70
|
|
|
24
71
|
/**
|
|
25
|
-
*
|
|
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.
|
|
72
|
+
* Divide by a prefix scale without showing the result of doing so in binary.
|
|
29
73
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* formatValue(1.5, 'Hz') // '1.5Hz'
|
|
74
|
+
* `0.0005 / 1e-6` is 500.00000000000006, and with no `digits` to round it that
|
|
75
|
+
* lands in the input as written.
|
|
33
76
|
*/
|
|
34
|
-
|
|
35
|
-
value
|
|
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
|
|
77
|
+
function scaleBy(value: number, scale: number): number {
|
|
78
|
+
return toPrecision(value / scale)
|
|
46
79
|
}
|
|
47
80
|
|
|
81
|
+
/** A number, then whatever followed it. */
|
|
82
|
+
const NUMBER_THEN_REST =
|
|
83
|
+
/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(.*)$/
|
|
84
|
+
|
|
48
85
|
/**
|
|
49
|
-
*
|
|
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.
|
|
50
91
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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'
|
|
55
97
|
*
|
|
56
98
|
* @example
|
|
57
|
-
*
|
|
58
|
-
* parseValue('4abc') // 4
|
|
99
|
+
* <NumberInput.Root {...unitFormat('Hz', { digits: 2 })} value={v} onChange={setV}>
|
|
59
100
|
*/
|
|
60
|
-
export function
|
|
61
|
-
|
|
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
|
+
}
|
|
62
119
|
|
|
63
|
-
if (!
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
}
|
|
67
138
|
}
|
|
68
139
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
}
|
|
73
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
|
|
6
|
+
return (a || b) && a !== b
|
|
42
7
|
}
|
package/src/types.ts
DELETED