diff --git a/package-lock.json b/package-lock.json index 552877f..9586a92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "adsr", "version": "0.4.2", "dependencies": { + "@dsp-ts/math": "0.1.0", "@elemaudio/core": "2.1.0", "@elemaudio/web-renderer": "2.1.0", "@radix-ui/react-icons": "1.3.0", @@ -16,6 +17,7 @@ "next": "13.4.19", "react": "18.2.0", "react-dom": "18.2.0", + "react-knob-headless": "0.1.1", "webmidi": "3.1.6" }, "devDependencies": { @@ -70,6 +72,11 @@ "node": ">=6.9.0" } }, + "node_modules/@dsp-ts/math": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@dsp-ts/math/-/math-0.1.0.tgz", + "integrity": "sha512-NDjfx30t9IPqbSKKYyjro0QXX4m6dgW4pnuAbNMI442ePKyn0xG/L2rTBYM5NoT3IP0Y2EA0hCPhXlVNvRb7Vg==" + }, "node_modules/@elemaudio/core": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@elemaudio/core/-/core-2.1.0.tgz", @@ -833,13 +840,13 @@ "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.2.21", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.21.tgz", "integrity": "sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -850,7 +857,7 @@ "version": "18.2.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", "integrity": "sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==", - "dev": true, + "devOptional": true, "dependencies": { "@types/react": "*" } @@ -859,7 +866,7 @@ "version": "0.16.3", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", - "dev": true + "devOptional": true }, "node_modules/@types/semver": { "version": "7.5.0", @@ -1724,7 +1731,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true + "devOptional": true }, "node_modules/debug": { "version": "4.3.4", @@ -3257,6 +3264,11 @@ "node": ">=12" } }, + "node_modules/merge-props": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/merge-props/-/merge-props-6.0.0.tgz", + "integrity": "sha512-ORZFZMGKE5PuAi7YfVCfPz3jiS9V0t2XXE2AGYiwMrcudRuj0hkXKEzsl17pUF07r+Digf9YlTzteX2LFE6vAQ==" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4067,6 +4079,46 @@ "dev": true, "peer": true }, + "node_modules/react-knob-headless": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/react-knob-headless/-/react-knob-headless-0.1.1.tgz", + "integrity": "sha512-SHrKIRcBTuFz8gXpEMG5sOTZicPWtJfCr2pt91uAc17n+4hjxRZkwjLlZrpmBvLnzLcaSkf1NPQBUe6uEzUMRQ==", + "dependencies": { + "@dsp-ts/math": "^0.1.0", + "@use-gesture/react": "^10.3.0", + "merge-props": "^6.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/react-knob-headless/node_modules/@use-gesture/core": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.0.tgz", + "integrity": "sha512-rh+6MND31zfHcy9VU3dOZCqGY511lvGcfyJenN4cWZe0u1BH6brBpBddLVXhF2r4BMqWbvxfsbL7D287thJU2A==" + }, + "node_modules/react-knob-headless/node_modules/@use-gesture/react": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.0.tgz", + "integrity": "sha512-3zc+Ve99z4usVP6l9knYVbVnZgfqhKah7sIG+PS2w+vpig2v2OLct05vs+ZXMzwxdNCMka8B+8WlOo0z6Pn6DA==", + "dependencies": { + "@use-gesture/core": "10.3.0" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5083,6 +5135,11 @@ "regenerator-runtime": "^0.14.0" } }, + "@dsp-ts/math": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@dsp-ts/math/-/math-0.1.0.tgz", + "integrity": "sha512-NDjfx30t9IPqbSKKYyjro0QXX4m6dgW4pnuAbNMI442ePKyn0xG/L2rTBYM5NoT3IP0Y2EA0hCPhXlVNvRb7Vg==" + }, "@elemaudio/core": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@elemaudio/core/-/core-2.1.0.tgz", @@ -5518,13 +5575,13 @@ "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true + "devOptional": true }, "@types/react": { "version": "18.2.21", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.21.tgz", "integrity": "sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==", - "dev": true, + "devOptional": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -5535,7 +5592,7 @@ "version": "18.2.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", "integrity": "sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==", - "dev": true, + "devOptional": true, "requires": { "@types/react": "*" } @@ -5544,7 +5601,7 @@ "version": "0.16.3", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", - "dev": true + "devOptional": true }, "@types/semver": { "version": "7.5.0", @@ -6118,7 +6175,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true + "devOptional": true }, "debug": { "version": "4.3.4", @@ -7238,6 +7295,11 @@ "@jridgewell/sourcemap-codec": "^1.4.15" } }, + "merge-props": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/merge-props/-/merge-props-6.0.0.tgz", + "integrity": "sha512-ORZFZMGKE5PuAi7YfVCfPz3jiS9V0t2XXE2AGYiwMrcudRuj0hkXKEzsl17pUF07r+Digf9YlTzteX2LFE6vAQ==" + }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7735,6 +7797,31 @@ "dev": true, "peer": true }, + "react-knob-headless": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/react-knob-headless/-/react-knob-headless-0.1.1.tgz", + "integrity": "sha512-SHrKIRcBTuFz8gXpEMG5sOTZicPWtJfCr2pt91uAc17n+4hjxRZkwjLlZrpmBvLnzLcaSkf1NPQBUe6uEzUMRQ==", + "requires": { + "@dsp-ts/math": "^0.1.0", + "@use-gesture/react": "^10.3.0", + "merge-props": "^6.0.0" + }, + "dependencies": { + "@use-gesture/core": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.0.tgz", + "integrity": "sha512-rh+6MND31zfHcy9VU3dOZCqGY511lvGcfyJenN4cWZe0u1BH6brBpBddLVXhF2r4BMqWbvxfsbL7D287thJU2A==" + }, + "@use-gesture/react": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.0.tgz", + "integrity": "sha512-3zc+Ve99z4usVP6l9knYVbVnZgfqhKah7sIG+PS2w+vpig2v2OLct05vs+ZXMzwxdNCMka8B+8WlOo0z6Pn6DA==", + "requires": { + "@use-gesture/core": "10.3.0" + } + } + } + }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index b8705b8..0ea1839 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "upgrade": "npm i $(npm outdated | cut -d' ' -f 1 | sed '1d' | xargs -I '$' echo '$@latest' | xargs echo) --save-exact" }, "dependencies": { + "@dsp-ts/math": "0.1.0", "@elemaudio/core": "2.1.0", "@elemaudio/web-renderer": "2.1.0", "@radix-ui/react-icons": "1.3.0", @@ -19,6 +20,7 @@ "next": "13.4.19", "react": "18.2.0", "react-dom": "18.2.0", + "react-knob-headless": "0.1.1", "webmidi": "3.1.6" }, "devDependencies": { diff --git a/src/components/pages/SynthPage/SynthPage.tsx b/src/components/pages/SynthPage/SynthPage.tsx index f08d785..8ee0239 100644 --- a/src/components/pages/SynthPage/SynthPage.tsx +++ b/src/components/pages/SynthPage/SynthPage.tsx @@ -2,21 +2,17 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import WebAudioRenderer from '@elemaudio/web-renderer'; import {el} from '@elemaudio/core'; +import {clamp, mapTo01Linear} from '@dsp-ts/math'; import resolveConfig from 'tailwindcss/resolveConfig'; import tailwindConfig from '@/../tailwind.config'; -import { - LinearSmoothedValueRealtime, - clamp, - dbMin, - gainToDecibels, - mapTo01Linear, -} from '@/utils/math'; +import {LinearSmoothedValueRealtime, dbMin, gainToDecibels} from '@/utils/math'; import {midiNoteToFreq} from '@/utils/math/midi'; import {keyCodes} from '@/constants/key-codes'; import {useElConst} from '@/components/hooks/useElConst'; import {useElConstBool} from '@/components/hooks/useElConstBool'; import {Meter} from '@/components/ui/Meter'; import {KnobPercentage} from '@/components/ui/KnobPercentage'; +import {KnobPercentage as KnobPercentageV2} from '@/components/ui/v2/KnobPercentage'; import {KnobAdr} from '@/components/ui/KnobAdr'; import {KnobFrequency} from '@/components/ui/KnobFrequency'; import {InteractionArea} from '@/components/ui/InteractionArea'; @@ -241,10 +237,9 @@ function SynthPageMain({ctx, core}: SynthPageMainProps) { void renderAudio(); }} /> - { sustainConst.update(newValue); void renderAudio(); @@ -319,6 +314,27 @@ const resolveKnobComponent = (kind: KnobInputKind) => { } }; +type KnobPercentageV2Props = React.ComponentProps; +type KnobPercentageV2WrappedProps = Omit; +function KnobPercentageV2Wrapped({ + label, + valueDefault, + onChange, +}: KnobPercentageV2WrappedProps) { + const [value, setValue] = useState(valueDefault); + return ( + { + setValue(newValue); + onChange(newValue); + }} + /> + ); +} + const useMeter = ({ core, meterRef, diff --git a/src/components/ui/Knob.tsx b/src/components/ui/Knob.tsx index 152e603..68695ad 100644 --- a/src/components/ui/Knob.tsx +++ b/src/components/ui/Knob.tsx @@ -1,9 +1,10 @@ import {keyCodes} from '@/constants/key-codes'; import {isNumberKey} from '@/utils/keyboard'; -import {clamp, clamp01, mapFrom01Linear, mapTo01Linear} from '@/utils/math'; import {useDrag} from '@use-gesture/react'; import clsx from 'clsx'; import {useEffect, useId, useRef, useState} from 'react'; +import {KnobHeadless} from 'react-knob-headless'; +import {clamp, clamp01, mapFrom01Linear, mapTo01Linear} from '@dsp-ts/math'; export type KnobProps = { isLarge?: boolean; diff --git a/src/components/ui/v2/KnobPercentage.tsx b/src/components/ui/v2/KnobPercentage.tsx new file mode 100644 index 0000000..03b8584 --- /dev/null +++ b/src/components/ui/v2/KnobPercentage.tsx @@ -0,0 +1,177 @@ +import {useEffect, useId, useRef, useState} from 'react'; +import {mapFrom01Linear, mapTo01Linear} from '@dsp-ts/math'; +import { + KnobHeadless, + KnobHeadlessLabel, + KnobHeadlessOutput, +} from 'react-knob-headless'; +import {keyCodes} from '@/constants/key-codes'; + +type KnobPercentageProps = { + label: string; + value: number; + valueDefault: number; + onChange: (newValue: number) => void; +}; + +export function KnobPercentage({ + label, + value, + valueDefault, + onChange, +}: KnobPercentageProps) { + const knobRef = useRef(null); + const knobId = useId(); + const labelId = useId(); + + const value01 = mapTo01Linear(value, valueMin, valueMax); + + const angleMin = -145; + const angleMax = 145; + const angle = mapFrom01Linear(value01, angleMin, angleMax); + + const [hasManualInputInitialValue, setHasManualInputInitialValue] = + useState(true); + const [isManualInputActive, setIsManualInputActive] = useState(false); + const manualInputInitialValue = hasManualInputInitialValue + ? value // TODO: use rounding like "toManualInputFn" + : undefined; + + const openManualInput = (withDefaultValue: boolean) => { + setHasManualInputInitialValue(withDefaultValue); + setIsManualInputActive(true); + }; + + const closeManualInput = () => { + setIsManualInputActive(false); + knobRef.current?.focus(); // Re-focus back on the knob + }; + + return ( +
+
{ + // Focus the knob when clicked on any part of the container + knobRef.current?.focus(); + }} + // TODO: add "onKeyDown" handler to the knob container ... + > + {label} + +
+
+
+
+
+ + { + openManualInput(true); + }} + > + {valueRawDisplayFn(value)} + + {isManualInputActive && ( + { + closeManualInput(); + onChange(fromManualInputFn(newValue)); + }} + /> + )} +
+
+ ); +} + +const valueMin = 0; +const valueMax = 1; +const dragSensitivity = 0.006; +const valueRawRoundFn = (valueRaw: number) => valueRaw; +const valueRawDisplayFn = (valueRaw: number) => { + const percent = valueRaw * 100; + return `${percent < 10 ? percent.toFixed(1) : percent.toFixed(0)} %`; +}; + +/** + * --------------- + * MANUAL INPUT + * --------------- + */ + +// TODO: complete this +const fromManualInputFn = (newValue: number): number => newValue; + +type ManualInputProps = { + initialValue?: number; + onCancel: () => void; + onSubmit: (newValue: number) => void; +}; + +function ManualInput({initialValue, onCancel, onSubmit}: ManualInputProps) { + const inputRef = useRef(null); + useEffect(() => { + inputRef.current?.focus(); // Focus on the input when it's mounted + }, []); + + const isCancelledRef = useRef(false); + + const submit = () => { + if (isCancelledRef.current) return; + onSubmit(Number(inputRef.current?.value)); + }; + + return ( +
{ + event.preventDefault(); // Prevent standard form submission behavior + submit(); + }} + > + { + // Prevent standard input behaviour when it's being changed on arrow up/down press + if ( + event.code === keyCodes.arrowDown || + event.code === keyCodes.arrowUp + ) { + event.preventDefault(); + return; + } + + // Cancel on escape + if (event.code === keyCodes.escape) { + isCancelledRef.current = true; + onCancel(); + } + }} + /> +
+ ); +} diff --git a/src/utils/math/NormalisableRange.ts b/src/utils/math/NormalisableRange.ts index 0847aba..2a6d61f 100644 --- a/src/utils/math/NormalisableRange.ts +++ b/src/utils/math/NormalisableRange.ts @@ -1,4 +1,4 @@ -import {clamp01} from './clamp'; +import {clamp01} from '@dsp-ts/math'; /** * Partial implementation of the "NormalisableRange" class from JUCE Framework. diff --git a/src/utils/math/clamp.test.ts b/src/utils/math/clamp.test.ts deleted file mode 100644 index f665e80..0000000 --- a/src/utils/math/clamp.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {describe, expect, it} from 'vitest'; -import {clamp, clamp01} from './clamp'; - -describe('clamp', () => { - it('returns X when it is within the range [MIN..MAX]', () => { - expect(clamp(5, 0, 10)).toBe(5); - expect(clamp(-2, -5, 5)).toBe(-2); - expect(clamp(100, 50, 200)).toBe(100); - }); - - it('returns MIN value when X is less than MIN', () => { - expect(clamp(-10, 0, 10)).toBe(0); - expect(clamp(-100, -50, 50)).toBe(-50); - expect(clamp(0, 10, 20)).toBe(10); - }); - - it('returns MAX value when X is greater than MAX', () => { - expect(clamp(15, 0, 10)).toBe(10); - expect(clamp(200, -50, 50)).toBe(50); - expect(clamp(25, 10, 20)).toBe(20); - }); -}); - -describe('clamp01', () => { - it('returns X when it is within the range [0..1]', () => { - expect(clamp01(0.5)).toBe(0.5); - expect(clamp01(0)).toBe(0); - expect(clamp01(1)).toBe(1); - }); - - it('returns 0 when X is less than 0', () => { - expect(clamp01(-0.5)).toBe(0); - expect(clamp01(-10)).toBe(0); - }); - - it('returns 1 when X is greater than 1', () => { - expect(clamp01(1.5)).toBe(1); - expect(clamp01(10)).toBe(1); - }); -}); diff --git a/src/utils/math/clamp.ts b/src/utils/math/clamp.ts deleted file mode 100644 index fcb8fb5..0000000 --- a/src/utils/math/clamp.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Clamps "x" value in [min..max] range - */ -export const clamp = (x: number, min: number, max: number): number => - Math.max(min, Math.min(max, x)); - -/** - * Clamps "x" value in [0..1] range - */ -export const clamp01 = (x: number): number => clamp(x, 0, 1); diff --git a/src/utils/math/index.ts b/src/utils/math/index.ts index 99fb74b..18bb74d 100644 --- a/src/utils/math/index.ts +++ b/src/utils/math/index.ts @@ -1,7 +1,5 @@ -export {clamp, clamp01} from './clamp'; export {gainToDecibels, dbMin} from './decibels'; export {LinearSmoothedValue} from './LinearSmoothedValue'; export {LinearSmoothedValueRealtime} from './LinearSmoothedValueRealtime'; export {NormalisableRange} from './NormalisableRange'; -export {mapFrom01Linear, mapTo01Linear} from './map01Linear'; export {round} from './round'; diff --git a/src/utils/math/map01Linear.test.ts b/src/utils/math/map01Linear.test.ts deleted file mode 100644 index 07f8917..0000000 --- a/src/utils/math/map01Linear.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {describe, expect, it} from 'vitest'; -import {mapFrom01Linear, mapTo01Linear} from './map01Linear'; - -describe('mapFrom01Linear', () => { - it('should correctly map values within the [0..1] range to [MIN..MAX] linearly', () => { - expect(mapFrom01Linear(0, 10, 20)).toBe(10); - expect(mapFrom01Linear(0.2, -10, 10)).toBeCloseTo(-6); - expect(mapFrom01Linear(0.5, 0, 100)).toBe(50); - expect(mapFrom01Linear(1, -50, 50)).toBe(50); - }); - - it('should return MIN when X = 0', () => { - expect(mapFrom01Linear(0, 5, 15)).toBe(5); - expect(mapFrom01Linear(0, -20, -10)).toBe(-20); - }); - - it('should return MAX when X = 1', () => { - expect(mapFrom01Linear(1, 5, 15)).toBe(15); - expect(mapFrom01Linear(1, -20, -10)).toBe(-10); - }); -}); - -describe('mapTo01Linear', () => { - it('should correctly map values within the [MIN..MAX] range to [0..1] linearly', () => { - expect(mapTo01Linear(10, 10, 20)).toBe(0); - expect(mapTo01Linear(15, 10, 20)).toBe(0.5); - expect(mapTo01Linear(20, 10, 20)).toBe(1); - expect(mapTo01Linear(-5, -10, 10)).toBe(0.25); - }); - - it('should return 0 when X = MIN', () => { - expect(mapTo01Linear(0, 0, 100)).toBe(0); - expect(mapTo01Linear(-50, -50, 50)).toBe(0); - }); - - it('should return 1 when X = MAX', () => { - expect(mapTo01Linear(100, 0, 100)).toBe(1); - expect(mapTo01Linear(50, -50, 50)).toBe(1); - }); -}); diff --git a/src/utils/math/map01Linear.ts b/src/utils/math/map01Linear.ts deleted file mode 100644 index 515f46c..0000000 --- a/src/utils/math/map01Linear.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Maps "x" value in [0..1] range onto [min..max] range linearly - */ -export const mapFrom01Linear = (x: number, min: number, max: number): number => - (max - min) * x + min; - -/** - * Maps "x" value in [min..max] range onto [0..1] range linearly - */ -export const mapTo01Linear = (x: number, min: number, max: number): number => - (x - min) / (max - min);