From cff232523b748b877b56880f57e28c3daa466111 Mon Sep 17 00:00:00 2001 From: Valentin Valls Date: Sun, 4 Jan 2026 18:15:42 +0100 Subject: [PATCH 1/5] Structure the knob state with a geometry --- lib/src/Arc.tsx | 3 ++- lib/src/Knob.tsx | 44 +++++++++++++++----------------------------- lib/src/Label.tsx | 5 +++-- lib/src/Pointer.tsx | 5 +++-- lib/src/Power.tsx | 19 +++++-------------- lib/src/Range.tsx | 44 ++++++++++++++++++-------------------------- lib/src/Scale.tsx | 5 +++-- lib/src/Spiral.tsx | 3 ++- lib/src/Value.tsx | 3 ++- lib/src/types.ts | 21 ++++++++++++++++----- 10 files changed, 69 insertions(+), 83 deletions(-) diff --git a/lib/src/Arc.tsx b/lib/src/Arc.tsx index 55ade03..4595e59 100644 --- a/lib/src/Arc.tsx +++ b/lib/src/Arc.tsx @@ -16,6 +16,7 @@ interface Props { export function Arc(props: Props) { const state = useKnobContext('Arc'); + const { geometry } = state; const { percentage } = state; const { className, @@ -23,7 +24,7 @@ export function Arc(props: Props) { arcWidth, color, background, - radius = state.radius, + radius = geometry.radius, } = props; return ( diff --git a/lib/src/Knob.tsx b/lib/src/Knob.tsx index 1ee21f0..77c883c 100644 --- a/lib/src/Knob.tsx +++ b/lib/src/Knob.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useEffect, useState } from 'react'; +import React, { PropsWithChildren, useMemo } from 'react'; import useUpdate from './useUpdate'; import type { InteractiveHook, KnobState } from 'types'; import { KnobContext } from './context'; @@ -79,41 +79,27 @@ export function Knob(props: PropsWithChildren) { onStart, onEnd, }); - const radius = size / 2; - const center = size / 2; - const [knobState, setKnobState] = useState({ - value, - percentage, - size, - angleOffset, - angleRange, - radius, - center, - steps, - }); - - useEffect(() => { - setKnobState({ - value, - percentage, + const geometry = useMemo(() => { + const radius = size / 2; + const center = size / 2; + return { size, angleOffset, angleRange, radius, center, + }; + }, [size, angleOffset, angleRange]); + + const knobState = useMemo(() => { + return { + value, + percentage, + geometry, steps, - }); - }, [ - value, - percentage, - size, - angleOffset, - angleRange, - radius, - center, - steps, - ]); + }; + }, [value, percentage, geometry, steps]); return (
) { const state = useKnobContext('Pointer'); - const { angleRange, angleOffset, center } = state; + const { geometry } = state; + const { angleRange, angleOffset, center } = geometry; const { children, width, @@ -65,7 +66,7 @@ export function Pointer(props: React.PropsWithChildren) { useRotation = true, type, percentage = state.percentage, - radius = state.radius, + radius = geometry.radius, color = 'black', className, } = props; diff --git a/lib/src/Power.tsx b/lib/src/Power.tsx index 25db085..1c85721 100644 --- a/lib/src/Power.tsx +++ b/lib/src/Power.tsx @@ -1,3 +1,4 @@ +import { KnobGeometry } from 'types'; import { useKnobContext } from './context'; import React, { useMemo } from 'react'; @@ -12,12 +13,6 @@ function degTorad(deg: number) { return (Math.PI * deg) / 180; } -interface Geometry { - angleOffset: number; - angleRange: number; - center: number; -} - interface Position { percentage: number; radius: number; @@ -25,7 +20,7 @@ interface Position { } function calcPath( - geometry: Geometry, + geometry: KnobGeometry, positionFrom: Position, positionTo: Position | null, ): string | null { @@ -97,23 +92,19 @@ interface Props { export function Power(props: Props) { const state = useKnobContext('Power'); - const { percentage, angleOffset, angleRange, center } = state; + const { percentage, geometry } = state; const { interpolateTo, color, percentageFrom, - radiusFrom = state.radius, + radiusFrom = geometry.radius, percentageTo, - radiusTo = state.radius, + radiusTo = geometry.radius, widthFrom, widthTo, className, } = props; - const geometry: Geometry = useMemo(() => { - return { angleOffset, angleRange, center }; - }, [angleOffset, angleRange, center]); - const positionFrom: Position = useMemo( () => ({ percentage: percentageFrom, diff --git a/lib/src/Range.tsx b/lib/src/Range.tsx index 4a73d75..4734b89 100644 --- a/lib/src/Range.tsx +++ b/lib/src/Range.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react'; import { useKnobContext } from './context'; +import { KnobGeometry } from 'types'; const pointOnCircle = (center: number, radius: number, angle: number) => ({ x: center + radius * Math.cos(angle), @@ -10,23 +11,14 @@ const degTorad = (deg: number) => (Math.PI * deg) / 180; const clampDeg = (deg: number) => deg >= 360 ? 359.999 : deg <= -360 ? -359.999 : deg; -const calcPath = ({ - percentageFrom, - percentageTo, - angleOffset, - angleRange, - arcWidth, - radius: outerRadius, - center, -}: { - percentageFrom: number; - percentageTo: number; - angleOffset: number; - angleRange: number; - arcWidth: number; - radius: number; - center: number; -}) => { +function calcPath( + geometry: KnobGeometry, + arcWidth: number, + outerRadius: number, + percentageFrom: number, + percentageTo: number, +) { + const { angleRange, angleOffset, center } = geometry; const angle = angleRange * (percentageTo - percentageFrom); const clampedAngle = clampDeg(angle); const angleFrom = angleOffset - 90 + angleRange * percentageFrom; @@ -48,7 +40,7 @@ const calcPath = ({ }L${p3.x},${p3.y} A${innerRadius},${innerRadius} 0 ${largeArcFlag} ${ 1 - direction } ${p4.x},${p4.y} L${p1.x},${p1.y}`; -}; +} interface Props { color?: string; @@ -65,12 +57,12 @@ interface Props { export function Range(props: Props) { const state = useKnobContext('Range'); - const { percentage } = state; + const { percentage, geometry } = state; const { color, percentageFrom = null, percentageTo = null, - radius = state.radius, + radius = geometry.radius, outerRadius, arcWidth, className, @@ -94,13 +86,13 @@ export function Range(props: Props) { if (pFrom === null || pTo === null) { return null; } - return calcPath({ + return calcPath( + geometry, arcWidth, - ...state, - radius: outerRadius ?? radius ?? state.radius, - percentageFrom: pFrom, - percentageTo: pTo, - }); + outerRadius ?? radius ?? geometry.radius, + pFrom, + pTo, + ); }, [pFrom, pTo, outerRadius, radius, state]); if (d === null) { diff --git a/lib/src/Scale.tsx b/lib/src/Scale.tsx index 0dff644..16ba443 100644 --- a/lib/src/Scale.tsx +++ b/lib/src/Scale.tsx @@ -118,11 +118,12 @@ interface Props { export function Scale(props: Props) { const state = useKnobContext('Scale'); - const { angleRange, angleOffset, percentage, center } = state; + const { percentage, geometry } = state; + const { angleRange, angleOffset, center } = geometry; const { steps = state.steps, type = 'rect', - radius = state.radius, + radius = geometry.radius, tickWidth, tickHeight, color, diff --git a/lib/src/Spiral.tsx b/lib/src/Spiral.tsx index e7a5946..f06180e 100644 --- a/lib/src/Spiral.tsx +++ b/lib/src/Spiral.tsx @@ -89,7 +89,8 @@ interface Props { export function Spiral(props: Props) { const state = useKnobContext('Spiral'); - const { percentage, angleOffset, angleRange, center } = state; + const { percentage, geometry } = state; + const { angleOffset, angleRange, center } = geometry; const { color, percentageFrom = null, diff --git a/lib/src/Value.tsx b/lib/src/Value.tsx index 1f713a2..70fa901 100644 --- a/lib/src/Value.tsx +++ b/lib/src/Value.tsx @@ -13,7 +13,8 @@ interface Props { export function Value(props: Props) { const state = useKnobContext('Value'); - const { size } = state; + const { geometry } = state; + const { size } = geometry; const { decimalPlace = 0, className, diff --git a/lib/src/types.ts b/lib/src/types.ts index b723b44..b84e575 100644 --- a/lib/src/types.ts +++ b/lib/src/types.ts @@ -1,3 +1,14 @@ +/** + * Part of the Knob state which is usually unchanged. + */ +export interface KnobGeometry { + size: number; + angleOffset: number; + angleRange: number; + radius: number; + center: number; +} + /** * Knob state as exposed to the sub components */ @@ -12,11 +23,11 @@ export interface KnobState { */ percentage: number | null; - size: number; - angleOffset: number; - angleRange: number; - radius: number; - center: number; + /** + * Part of the state which is usually unchanged. + */ + geometry: KnobGeometry; + steps?: number; } From 910c60233bbc06c62bddec4ac99f73b1c64a630f Mon Sep 17 00:00:00 2001 From: Valentin Valls Date: Sun, 4 Jan 2026 19:09:39 +0100 Subject: [PATCH 2/5] Rework the center as a tuple for future flexibility --- docusaurus/docs/3_examples/2_custom.mdx | 2 +- .../src/components/DnlGradientKnob3/index.tsx | 2 +- docusaurus/src/components/GradientKnob3/index.tsx | 2 +- lib/src/Knob.tsx | 8 ++++---- lib/src/Label.tsx | 12 ++++++++---- lib/src/Pointer.tsx | 12 ++++++------ lib/src/Power.tsx | 10 +++++++--- lib/src/Range.tsx | 15 +++++++++++---- lib/src/Scale.tsx | 10 +++++----- lib/src/Spiral.tsx | 12 ++++++++---- lib/src/types.ts | 5 ++++- 11 files changed, 56 insertions(+), 34 deletions(-) diff --git a/docusaurus/docs/3_examples/2_custom.mdx b/docusaurus/docs/3_examples/2_custom.mdx index b1a89ea..ac55383 100644 --- a/docusaurus/docs/3_examples/2_custom.mdx +++ b/docusaurus/docs/3_examples/2_custom.mdx @@ -91,7 +91,7 @@ function Example(props) { height={i === active ? tickHeight * 3 : tickHeight} key={i} transform={` - rotate(${angleOffset + stepSize * i} ${center} ${center}) + rotate(${angleOffset + stepSize * i} ${center[0]} ${center[1]}) translate( ${translateX} ${translateY}) `} /> diff --git a/docusaurus/src/components/DnlGradientKnob3/index.tsx b/docusaurus/src/components/DnlGradientKnob3/index.tsx index 6e383c8..50da13c 100644 --- a/docusaurus/src/components/DnlGradientKnob3/index.tsx +++ b/docusaurus/src/components/DnlGradientKnob3/index.tsx @@ -27,7 +27,7 @@ function customScaleTick({ className={className} key={i} transform={` - rotate(${angleOffset + stepSize * i} ${center} ${center}) + rotate(${angleOffset + stepSize * i} ${center[0]} ${center[1]}) translate( ${translateX} ${translateY}) `} /> diff --git a/docusaurus/src/components/GradientKnob3/index.tsx b/docusaurus/src/components/GradientKnob3/index.tsx index edd4dd2..1e606b2 100644 --- a/docusaurus/src/components/GradientKnob3/index.tsx +++ b/docusaurus/src/components/GradientKnob3/index.tsx @@ -31,7 +31,7 @@ export default function KnobExample(props: { size: number }) { fill={color} key={i} transform={` - rotate(${angleOffset + stepSize * i} ${center} ${center}) + rotate(${angleOffset + stepSize * i} ${center[0]} ${center[1]}) translate( ${translateX} ${translateY}) `} /> diff --git a/lib/src/Knob.tsx b/lib/src/Knob.tsx index 77c883c..7a76093 100644 --- a/lib/src/Knob.tsx +++ b/lib/src/Knob.tsx @@ -1,6 +1,6 @@ import React, { PropsWithChildren, useMemo } from 'react'; import useUpdate from './useUpdate'; -import type { InteractiveHook, KnobState } from 'types'; +import type { InteractiveHook, KnobGeometry, KnobState } from 'types'; import { KnobContext } from './context'; interface Props { @@ -80,15 +80,15 @@ export function Knob(props: PropsWithChildren) { onEnd, }); - const geometry = useMemo(() => { + const geometry = useMemo(() => { const radius = size / 2; - const center = size / 2; + const cx = size / 2; return { size, angleOffset, angleRange, radius, - center, + center: [cx, cx], }; }, [size, angleOffset, angleRange]); diff --git a/lib/src/Label.tsx b/lib/src/Label.tsx index b73e13d..79dcf19 100644 --- a/lib/src/Label.tsx +++ b/lib/src/Label.tsx @@ -1,11 +1,15 @@ import React, { useMemo } from 'react'; import { useKnobContext } from './context'; -function pointOnCircle(center: number, radius: number, angle: number) { +function pointOnCircle( + center: [number, number], + radius: number, + angle: number, +) { const rad = (angle * Math.PI) / 180; return { - x: center + radius * Math.cos(rad), - y: center + radius * Math.sin(rad), + x: center[0] + radius * Math.cos(rad), + y: center[1] + radius * Math.sin(rad), }; } @@ -47,7 +51,7 @@ export function Label(props: Props) { return <>; } return ( - + ) { return null; } if (useRotation) { - return `rotate(${ - angleOffset + angleRange * percentage - } ${center} ${center}) - translate( ${center} ${center - radius - (height ?? 0)})`; + return `rotate(${angleOffset + angleRange * percentage} ${ + center[0] + } ${center[1]}) + translate( ${center[0]} ${center[1] - radius - (height ?? 0)})`; } else { const angle = ((angleOffset + angleRange * percentage - 90) * Math.PI) / 180; - const x = center + radius * Math.cos(angle); - const y = center + radius * Math.sin(angle); + const x = center[0] + radius * Math.cos(angle); + const y = center[1] + radius * Math.sin(angle); return `translate(${x} ${y})`; } }, [percentage, angleOffset, angleRange, center, radius, height]); diff --git a/lib/src/Power.tsx b/lib/src/Power.tsx index 1c85721..1242d9d 100644 --- a/lib/src/Power.tsx +++ b/lib/src/Power.tsx @@ -2,10 +2,14 @@ import { KnobGeometry } from 'types'; import { useKnobContext } from './context'; import React, { useMemo } from 'react'; -function pointOnCircle(center: number, radius: number, angle: number) { +function pointOnCircle( + center: [number, number], + radius: number, + angle: number, +) { return { - x: center + radius * Math.cos(angle), - y: center + radius * Math.sin(angle), + x: center[0] + radius * Math.cos(angle), + y: center[1] + radius * Math.sin(angle), }; } diff --git a/lib/src/Range.tsx b/lib/src/Range.tsx index 4734b89..d2127d3 100644 --- a/lib/src/Range.tsx +++ b/lib/src/Range.tsx @@ -2,10 +2,17 @@ import React, { useMemo } from 'react'; import { useKnobContext } from './context'; import { KnobGeometry } from 'types'; -const pointOnCircle = (center: number, radius: number, angle: number) => ({ - x: center + radius * Math.cos(angle), - y: center + radius * Math.sin(angle), -}); +function pointOnCircle( + center: [number, number], + radius: number, + angle: number, +) { + return { + x: center[0] + radius * Math.cos(angle), + y: center[1] + radius * Math.sin(angle), + }; +} + const degTorad = (deg: number) => (Math.PI * deg) / 180; const clampDeg = (deg: number) => diff --git a/lib/src/Scale.tsx b/lib/src/Scale.tsx index 16ba443..c896547 100644 --- a/lib/src/Scale.tsx +++ b/lib/src/Scale.tsx @@ -6,7 +6,7 @@ interface RenderProps { translateY: number; angleOffset: number; stepSize: number; - center: number; + center: [number, number]; color?: string; className?: string; active: number; @@ -35,7 +35,7 @@ function renderCircle({ fill={i === active ? activeColor : color} stroke="none" transform={` - rotate(${angleOffset + stepSize * i} ${center} ${center}) + rotate(${angleOffset + stepSize * i} ${center[0]} ${center[1]}) translate(${translateX} ${translateY}) `} /> @@ -67,7 +67,7 @@ function renderRect({ height={tickHeight} key={i} transform={` - rotate(${angleOffset + stepSize * i} ${center} ${center}) + rotate(${angleOffset + stepSize * i} ${center[0]} ${center[1]}) translate(${translateX} ${translateY}) `} /> @@ -137,8 +137,8 @@ export function Scale(props: Props) { } const stepSize = angleRange / steps; const length = steps + (angleRange === 360 ? 0 : 1); - const translateX = center - tickWidth / 2; - const translateY = center - radius; + const translateX = center[0] - tickWidth / 2; + const translateY = center[1] - radius; if (percentage === null) { return <>; } diff --git a/lib/src/Spiral.tsx b/lib/src/Spiral.tsx index f06180e..674a553 100644 --- a/lib/src/Spiral.tsx +++ b/lib/src/Spiral.tsx @@ -1,10 +1,14 @@ import { useKnobContext } from './context'; import React, { useMemo } from 'react'; -function pointOnCircle(center: number, radius: number, angle: number) { +function pointOnCircle( + center: [number, number], + radius: number, + angle: number, +) { return { - x: center + radius * Math.cos(angle), - y: center + radius * Math.sin(angle), + x: center[0] + radius * Math.cos(angle), + y: center[1] + radius * Math.sin(angle), }; } @@ -28,7 +32,7 @@ function calcPath(props: { arcWidth: number; outerRadiusFrom: number; outerRadiusTo: number; - center: number; + center: [number, number]; }) { const { percentageFrom, diff --git a/lib/src/types.ts b/lib/src/types.ts index b84e575..ae07a4f 100644 --- a/lib/src/types.ts +++ b/lib/src/types.ts @@ -6,7 +6,10 @@ export interface KnobGeometry { angleOffset: number; angleRange: number; radius: number; - center: number; + /** + * X and Y position of the center related to the upper left corner. + */ + center: [number, number]; } /** From c6a5c3f804f55694c855c81f3819c4ccd446da58 Mon Sep 17 00:00:00 2001 From: Valentin Valls Date: Sun, 4 Jan 2026 19:12:30 +0100 Subject: [PATCH 3/5] Ignore less stuffs --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 676edec..f25839b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ node_modules /coverage /lib/dist .* -_* \ No newline at end of file +/_* \ No newline at end of file From 14d387470b79c8c9cf9ced8d8a8a05be3e82df5c Mon Sep 17 00:00:00 2001 From: Valentin Valls Date: Sun, 4 Jan 2026 19:13:08 +0100 Subject: [PATCH 4/5] test: Added coverage for Power --- lib/test/Power.spec.tsx | 27 ++++++++++++++++++++++ lib/test/__snapshots__/Power.spec.tsx.snap | 9 ++++++++ 2 files changed, 36 insertions(+) create mode 100644 lib/test/Power.spec.tsx create mode 100644 lib/test/__snapshots__/Power.spec.tsx.snap diff --git a/lib/test/Power.spec.tsx b/lib/test/Power.spec.tsx new file mode 100644 index 0000000..9c4d28d --- /dev/null +++ b/lib/test/Power.spec.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { Knob, Power } from '../src'; + +describe('Power', () => { + it('renders correctlty', () => { + const { container } = render( + + + , + ); + expect(container.children[0].children[0].children[0]).toMatchSnapshot(); + }); +}); diff --git a/lib/test/__snapshots__/Power.spec.tsx.snap b/lib/test/__snapshots__/Power.spec.tsx.snap new file mode 100644 index 0000000..3f3d1c9 --- /dev/null +++ b/lib/test/__snapshots__/Power.spec.tsx.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Power > renders correctlty 1`] = ` + + + +`; From fcdfba4805f5356b666b599d17351cd450cb459c Mon Sep 17 00:00:00 2001 From: Valentin Valls Date: Sun, 4 Jan 2026 19:14:27 +0100 Subject: [PATCH 5/5] doc: Added missing meta files --- docusaurus/docs/1_base_components/_category_.json | 9 +++++++++ docusaurus/docs/3_examples/_category_.json | 9 +++++++++ 2 files changed, 18 insertions(+) create mode 100644 docusaurus/docs/1_base_components/_category_.json create mode 100644 docusaurus/docs/3_examples/_category_.json diff --git a/docusaurus/docs/1_base_components/_category_.json b/docusaurus/docs/1_base_components/_category_.json new file mode 100644 index 0000000..b38bc40 --- /dev/null +++ b/docusaurus/docs/1_base_components/_category_.json @@ -0,0 +1,9 @@ +{ + "label": "Base components", + "position": 1, + "collapsible": false, + "link": { + "type": "generated-index", + "description": "Provided components" + } +} diff --git a/docusaurus/docs/3_examples/_category_.json b/docusaurus/docs/3_examples/_category_.json new file mode 100644 index 0000000..083c7c2 --- /dev/null +++ b/docusaurus/docs/3_examples/_category_.json @@ -0,0 +1,9 @@ +{ + "label": "Examples", + "position": 3, + "collapsible": false, + "link": { + "type": "generated-index", + "description": "Few examples" + } +}