From a1ff9fe7d1e3c9a0bf7eee0f1f9f9f9ba19d0f1f Mon Sep 17 00:00:00 2001
From: Lucas Lee <135578805+gooddev97@users.noreply.github.com>
Date: Fri, 4 Sep 2026 16:43:24 +0700
Subject: [PATCH] feat: add camera zoom dial
---
CHANGELOG.md | 9 +
README.md | 101 ++-
apps/example/src/app/index.tsx | 98 +--
eslint.config.js | 2 +
packages/dial-slider/README.md | 9 +-
packages/dial-slider/package.json | 5 +-
.../camera-zoom-dial/CameraZoomDial.tsx | 760 ++++++++++++++++++
.../components/camera-zoom-dial/constants.ts | 42 +
.../src/components/camera-zoom-dial/types.ts | 50 ++
.../src/hooks/useCameraZoomMotion.ts | 295 +++++++
packages/dial-slider/src/index.ts | 5 +
.../camera-zoom-dial/camera-zoom-math.ts | 377 +++++++++
.../tests/camera-zoom-math.test.ts | 225 ++++++
packages/dial-slider/tests/public-api.test.ts | 3 +
.../dial-slider/tests/public-api.types.tsx | 18 +-
15 files changed, 1926 insertions(+), 73 deletions(-)
create mode 100644 packages/dial-slider/src/components/camera-zoom-dial/CameraZoomDial.tsx
create mode 100644 packages/dial-slider/src/components/camera-zoom-dial/constants.ts
create mode 100644 packages/dial-slider/src/components/camera-zoom-dial/types.ts
create mode 100644 packages/dial-slider/src/hooks/useCameraZoomMotion.ts
create mode 100644 packages/dial-slider/src/utils/camera-zoom-dial/camera-zoom-math.ts
create mode 100644 packages/dial-slider/tests/camera-zoom-math.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ad3cdb..0b265e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Added `CameraZoomDial`, an accessible compact/expanded camera zoom control with
+ configurable optical stops, focal-length labels, logarithmic wheel geometry,
+ touch-and-hold expansion, snapping, controlled or uncontrolled state, and a
+ measured pointer cutout that masks rotating ticks like the iPhone Camera UI.
+- Added a camera-style example, zoom geometry regression tests, public type
+ coverage, and API documentation.
+
## [0.1.0] - 2026-08-30
### Added
diff --git a/README.md b/README.md
index 202a8ab..82a48ab 100644
--- a/README.md
+++ b/README.md
@@ -3,9 +3,9 @@
[](https://github.com/ngocdevv/dial-slider/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/@ngocdevv/dial-slider)
-An animated, photo-style dial slider component for React Native and Expo. It
-combines a gesture-driven ruler with single- or multi-preset adjustment controls,
-independent values, progress rings, and accessible increment/decrement actions.
+Animated, photo-style dial controls for React Native and Expo. The package
+includes a multi-preset adjustment ruler and an iPhone-camera-style zoom wheel,
+with UI-runtime gestures and accessible increment/decrement actions.
## Demo
@@ -13,6 +13,10 @@ independent values, progress rings, and accessible increment/decrement actions.
## Features
+- `CameraZoomDial` provides compact optical-stop buttons plus a logarithmic,
+ circular zoom scale that expands on touch-and-hold.
+- Camera zoom motion follows the wheel tangent, spaces every zoom doubling by
+ 20 degrees, snaps to the requested step, and reports a final value.
- One preset renders a centered adjustment tool; multiple presets render a
horizontally scrollable strip.
- Each preset keeps an independent value, range, initial value, icon, label,
@@ -83,6 +87,44 @@ export function App() {
The library package does not depend on Expo Router. `expo-linear-gradient` is
the only Expo module used by the component itself.
+## Camera zoom wheel
+
+Tap a compact stop to jump to it, or tap the selected stop to open the adjustable
+wheel. Touch and hold the control, then drag left or right for continuous zoom.
+In uncontrolled expansion mode, the wheel returns to its compact state 1.2
+seconds after an interaction finishes.
+
+```tsx
+import { useState } from 'react';
+import { CameraZoomDial, type CameraZoomStop } from '@ngocdevv/dial-slider';
+
+const ZOOM_STOPS: readonly CameraZoomStop[] = [
+ { value: 0.5, focalLength: '13MM' },
+ { value: 1, focalLength: '26MM' },
+ { value: 2 },
+];
+
+export function CameraZoomControl() {
+ const [zoom, setZoom] = useState(1);
+
+ return (
+
+ );
+}
+```
+
+Use `defaultValue` instead of `value` for uncontrolled zoom. Use `expanded` and
+`onExpandedChange` to own the compact/expanded state, or `defaultExpanded` when
+the wheel should initially be open.
+
## Single preset
```tsx
@@ -176,6 +218,41 @@ export function PhotoAdjustments() {
## API reference
+### `CameraZoomDialProps`
+
+| Prop | Type | Default | Description |
+| -------------------- | ----------------------------- | ------------------------ | ----------------------------------------------------------------------------------- |
+| `minZoom` | `number` | `0.5` | Smallest positive zoom factor. Reversed and invalid ranges are normalized. |
+| `maxZoom` | `number` | `10` | Largest zoom factor. |
+| `step` | `number` | `0.1` | Visible, callback, snapping, and accessibility precision. |
+| `value` | `number` | `undefined` | Controlled zoom factor. |
+| `defaultValue` | `number` | `1` when in range | Initial uncontrolled zoom factor. |
+| `zoomStops` | `readonly CameraZoomStop[]` | In-range `0.5`, `1`, `2` | Optical/quick stops; empty or invalid input falls back to the in-range defaults. |
+| `expanded` | `boolean` | `undefined` | Controlled wheel expansion state. |
+| `defaultExpanded` | `boolean` | `false` | Initial uncontrolled expansion state. |
+| `onExpandedChange` | `(expanded: boolean) => void` | `undefined` | Reports requested compact/expanded state changes. |
+| `onZoomChange` | `(zoom: number) => void` | `undefined` | Reports each crossed step during a drag or animated stop change. |
+| `onInteractionStart` | `() => void` | `undefined` | Called when a wheel drag or quick-stop transition begins. |
+| `onInteractionEnd` | `(zoom: number) => void` | `undefined` | Called with the final snapped value. |
+| `formatValue` | `(zoom: number) => string` | Numeric value plus `x` | Formats the current visible and accessible value. |
+| `accentColor` | `string` | `#FFD60A` | Pointer, active value, focal length, and selected compact label. |
+| `surfaceColor` | `string` | 50% black | Expanded circular surface color. |
+| `labelColor` | `string` | `#FFFFFF` | Inactive stop label color. |
+| `disabled` | `boolean` | `false` | Disables drag, quick-stop, and accessibility value changes. |
+| `accessibilityLabel` | `string` | `Camera zoom` | Adjustable wheel label. |
+| `accessibilityHint` | `string` | Interaction instructions | Adjustable wheel hint. |
+| `style` | `StyleProp` | `undefined` | Root style. Width determines the measured wheel geometry; height follows its ratio. |
+| `testID` | `string` | `undefined` | Root identifier; stop IDs append `-stop-{value}`. |
+
+### `CameraZoomStop`
+
+| Field | Type | Default | Description |
+| -------------- | -------- | ------------ | ------------------------------------------------------------------- |
+| `value` | `number` | Required | Positive factor, normalized to the nearest configured `step`. |
+| `label` | `string` | Numeric | Circular wheel label without an automatically appended `x`. |
+| `compactLabel` | `string` | Camera style | Inactive compact label; fractions omit the leading zero by default. |
+| `focalLength` | `string` | None | Optional equivalent focal length, such as `13MM` or `26MM`. |
+
### `DialSliderProps`
| Prop | Type | Default | Description |
@@ -211,6 +288,12 @@ export function PhotoAdjustments() {
### Callback semantics
+- `CameraZoomDial` reports discrete `step` values while its scale moves. In
+ controlled mode, `value` remains the source of truth; in uncontrolled mode,
+ the component applies each proposal internally.
+- Controlled `expanded` state is changed only by its owner. In uncontrolled
+ mode, the component expands for a wheel gesture and automatically collapses
+ after the interaction.
- `onPresetChange(presetId, value)` reports a user selection request and the
current value owned by that preset. Uncontrolled mode also reports an
automatic fallback when the active preset is removed or disabled.
@@ -234,12 +317,12 @@ of release performance.
## Accessibility
-The active ruler exposes `adjustable`, its normalized range and current value,
-disabled state, and increment/decrement actions. Preset buttons expose selected
-and disabled state plus their values. Supply concise preset labels, use
-`formatValue` for units, and provide an explicit `accessibilityLabel` when the
-surrounding context is not obvious. Reanimated transitions honor the system
-reduced-motion preference.
+The expanded camera wheel and active adjustment ruler expose `adjustable`, their
+normalized range and current value, disabled state, and increment/decrement
+actions. Compact zoom stops and preset controls expose button selection and
+disabled state. Supply concise labels, use `formatValue` for units, and provide
+an explicit `accessibilityLabel` when the surrounding context is not obvious.
+Reanimated transitions honor the system reduced-motion preference.
## Development
diff --git a/apps/example/src/app/index.tsx b/apps/example/src/app/index.tsx
index 3de6282..1d28e13 100644
--- a/apps/example/src/app/index.tsx
+++ b/apps/example/src/app/index.tsx
@@ -1,7 +1,12 @@
-import React, { useCallback, useState } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
+import React from 'react';
+import { ScrollView, StyleSheet, Text } from 'react-native';
-import { DialSlider, type DialPreset } from '@ngocdevv/dial-slider';
+import {
+ CameraZoomDial,
+ DialSlider,
+ type CameraZoomStop,
+ type DialPreset,
+} from '@ngocdevv/dial-slider';
function PresetGlyph({
children,
@@ -17,11 +22,12 @@ function PresetGlyph({
);
}
-/**
- * Demo tool list. Length decides chrome:
- * - 1 item → single centered tool + ruler
- * - N items → horizontal preset strip + shared ruler
- */
+const CAMERA_ZOOM_STOPS: readonly CameraZoomStop[] = [
+ { value: 0.5, focalLength: '13MM' },
+ { value: 1, focalLength: '26MM' },
+ { value: 2 },
+];
+
const PHOTO_PRESETS: readonly DialPreset[] = [
{
id: 'exposure',
@@ -82,42 +88,30 @@ const PHOTO_PRESETS: readonly DialPreset[] = [
];
export default function HomeScreen() {
- const [activePreset, setActivePreset] = useState('highlights');
- const [activeValue, setActiveValue] = useState(0);
-
- const handlePresetChange = useCallback((presetId: string, value: number) => {
- setActivePreset(presetId);
- setActiveValue(value);
- }, []);
-
- const handleValueChange = useCallback((presetId: string, value: number) => {
- setActivePreset(presetId);
- setActiveValue(value);
- }, []);
-
- const activeLabel =
- PHOTO_PRESETS.find((preset) => preset.id === activePreset)?.label ??
- activePreset;
-
return (
-
-
- Photo adjustments
-
- {activeLabel} · {activeValue}
-
+
+
-
-
-
-
-
+
+
);
}
@@ -127,24 +121,10 @@ const styles = StyleSheet.create({
backgroundColor: '#000000',
},
content: {
- flex: 1,
+ flexGrow: 1,
+ gap: 16,
justifyContent: 'center',
- },
- title: {
- color: '#FFFFFF',
- fontSize: 22,
- fontWeight: '700',
- textAlign: 'center',
- },
- caption: {
- color: '#8E8E93',
- fontSize: 14,
- marginTop: 6,
- marginBottom: 24,
- textAlign: 'center',
- },
- dialSurface: {
- width: '100%',
+ paddingVertical: 24,
},
glyph: {
color: '#E4E4E7',
diff --git a/eslint.config.js b/eslint.config.js
index 07608b3..0f76181 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -26,7 +26,9 @@ module.exports = defineConfig([
// purity rules report false positives for these worklet-facing modules.
files: [
'packages/dial-slider/src/components/dial-slider/**/*.{ts,tsx}',
+ 'packages/dial-slider/src/components/camera-zoom-dial/CameraZoomDial.tsx',
'packages/dial-slider/src/hooks/useDialRulerMotion.ts',
+ 'packages/dial-slider/src/hooks/useCameraZoomMotion.ts',
],
rules: {
'react-hooks/refs': 'off',
diff --git a/packages/dial-slider/README.md b/packages/dial-slider/README.md
index 5c08e1a..d68f4c6 100644
--- a/packages/dial-slider/README.md
+++ b/packages/dial-slider/README.md
@@ -1,8 +1,8 @@
# @ngocdevv/dial-slider
-Animated, photo-style dial slider for React Native and Expo with single- and
-multi-preset modes, independent values, accessible actions, and UI-runtime
-gesture motion.
+Animated, photo-style dial controls for React Native and Expo: a single- or
+multi-preset adjustment ruler plus an iPhone-camera-style logarithmic zoom wheel.
+Both provide accessible actions and UI-runtime gesture motion.
```bash
bun add @ngocdevv/dial-slider
@@ -12,7 +12,10 @@ npx expo install expo-linear-gradient react-native-gesture-handler \
```tsx
import {
+ CameraZoomDial,
DialSlider,
+ type CameraZoomDialProps,
+ type CameraZoomStop,
type DialPreset,
type DialSliderProps,
} from '@ngocdevv/dial-slider';
diff --git a/packages/dial-slider/package.json b/packages/dial-slider/package.json
index 861ad62..759249d 100644
--- a/packages/dial-slider/package.json
+++ b/packages/dial-slider/package.json
@@ -1,7 +1,7 @@
{
"name": "@ngocdevv/dial-slider",
"version": "0.1.0",
- "description": "Animated photo-style dial slider component for React Native and Expo.",
+ "description": "Animated dial slider and camera zoom wheel components for React Native and Expo.",
"type": "commonjs",
"source": "./src/index.ts",
"main": "./lib/commonjs/index.js",
@@ -41,6 +41,9 @@
"expo",
"slider",
"dial",
+ "camera",
+ "zoom",
+ "wheel",
"reanimated",
"gesture-handler",
"photo-editor",
diff --git a/packages/dial-slider/src/components/camera-zoom-dial/CameraZoomDial.tsx b/packages/dial-slider/src/components/camera-zoom-dial/CameraZoomDial.tsx
new file mode 100644
index 0000000..75d57c7
--- /dev/null
+++ b/packages/dial-slider/src/components/camera-zoom-dial/CameraZoomDial.tsx
@@ -0,0 +1,760 @@
+import React, {
+ useCallback,
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import {
+ type AccessibilityActionEvent,
+ type LayoutChangeEvent,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native';
+import {
+ GestureDetector,
+ GestureHandlerRootView,
+} from 'react-native-gesture-handler';
+import Animated, {
+ Easing,
+ interpolate,
+ ReduceMotion,
+ useAnimatedStyle,
+ useReducedMotion,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+import Svg, {
+ Circle,
+ Defs,
+ G,
+ Line,
+ Mask,
+ Path,
+ Rect,
+ Text as SvgText,
+} from 'react-native-svg';
+
+import { useCameraZoomMotion } from '../../hooks/useCameraZoomMotion';
+import {
+ buildCameraZoomTicks,
+ CAMERA_ZOOM_DEFAULTS,
+ cameraZoomToAngle,
+ findCameraZoomStop,
+ findNearestCameraZoomStop,
+ formatCameraZoomCompactNumber,
+ formatCameraZoomNumber,
+ formatCameraZoomValue,
+ getCameraZoomRoundedTrianglePath,
+ getCameraZoomTriangleHalfWidth,
+ getSafeCameraZoomStep,
+ isCameraZoomMajorTick,
+ normalizeCameraZoomRange,
+ normalizeCameraZoomStops,
+ roundCameraZoom,
+} from '../../utils/camera-zoom-dial/camera-zoom-math';
+import {
+ CAMERA_ZOOM_DIAL_COLORS,
+ CAMERA_ZOOM_DIAL_GEOMETRY,
+} from './constants';
+import type { CameraZoomDialProps, CameraZoomStop } from './types';
+
+export type { CameraZoomDialProps, CameraZoomStop } from './types';
+
+const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1);
+const CENTER_LABEL_EXCLUSION_DEGREES = 8;
+const AnimatedG = Animated.createAnimatedComponent(G);
+
+interface Point {
+ x: number;
+ y: number;
+}
+
+function getPolarPoint(center: number, radius: number, angle: number): Point {
+ const radians = (angle * Math.PI) / 180;
+ return {
+ x: center + Math.sin(radians) * radius,
+ y: center - Math.cos(radians) * radius,
+ };
+}
+
+function getDefaultZoomStops(min: number, max: number) {
+ const preferred = [0.5, 1, 2].filter((value) => value >= min && value <= max);
+ return (preferred.length > 0 ? preferred : [min]).map((value) => ({ value }));
+}
+
+/**
+ * iPhone-camera-style logarithmic zoom wheel. Touch a compact zoom stop to
+ * jump to it, or touch and hold then drag horizontally for fine adjustment.
+ */
+export function CameraZoomDial({
+ minZoom = CAMERA_ZOOM_DEFAULTS.MIN,
+ maxZoom = CAMERA_ZOOM_DEFAULTS.MAX,
+ step: requestedStep = CAMERA_ZOOM_DEFAULTS.STEP,
+ value: controlledValue,
+ defaultValue,
+ zoomStops: zoomStopInput,
+ expanded: controlledExpanded,
+ defaultExpanded = false,
+ onExpandedChange,
+ onZoomChange,
+ onInteractionStart,
+ onInteractionEnd,
+ formatValue,
+ accentColor = CAMERA_ZOOM_DIAL_COLORS.ACCENT,
+ surfaceColor = CAMERA_ZOOM_DIAL_COLORS.SURFACE,
+ labelColor = CAMERA_ZOOM_DIAL_COLORS.LABEL,
+ disabled = false,
+ accessibilityLabel = 'Camera zoom',
+ accessibilityHint = 'Touch and hold, then drag left or right. You can also use increment and decrement actions.',
+ style,
+ testID,
+}: CameraZoomDialProps) {
+ const range = useMemo(
+ () => normalizeCameraZoomRange(minZoom, maxZoom),
+ [maxZoom, minZoom]
+ );
+ const step = getSafeCameraZoomStep(requestedStep);
+ const zoomStops = useMemo(() => {
+ const normalizedStops = normalizeCameraZoomStops(
+ zoomStopInput ?? getDefaultZoomStops(range.min, range.max),
+ range.min,
+ range.max,
+ step
+ );
+ return normalizedStops.length > 0
+ ? normalizedStops
+ : normalizeCameraZoomStops(
+ getDefaultZoomStops(range.min, range.max),
+ range.min,
+ range.max,
+ step
+ );
+ }, [range.max, range.min, step, zoomStopInput]);
+
+ const initialZoom = roundCameraZoom(
+ defaultValue ?? (range.min <= 1 && range.max >= 1 ? 1 : range.min),
+ step,
+ range.min,
+ range.max
+ );
+ const zoomIsControlled = controlledValue !== undefined;
+ const [uncontrolledZoom, setUncontrolledZoom] = useState(initialZoom);
+ const resolvedZoom = roundCameraZoom(
+ zoomIsControlled ? controlledValue : uncontrolledZoom,
+ step,
+ range.min,
+ range.max
+ );
+
+ const expansionIsControlled = controlledExpanded !== undefined;
+ const [uncontrolledExpanded, setUncontrolledExpanded] =
+ useState(defaultExpanded);
+ const resolvedExpanded = expansionIsControlled
+ ? controlledExpanded
+ : uncontrolledExpanded;
+ const expandedRef = useRef(resolvedExpanded);
+ const collapseTimerRef = useRef | null>(null);
+
+ const [layoutWidth, setLayoutWidth] = useState(0);
+ const componentId = useId();
+ const pointerMaskId = `camera-zoom-pointer-mask-${componentId.replace(
+ /[^a-zA-Z0-9_-]/g,
+ ''
+ )}`;
+ const reducedMotion = useReducedMotion();
+ const expansionProgress = useSharedValue(resolvedExpanded ? 1 : 0);
+
+ const onZoomChangeRef = useRef(onZoomChange);
+ const onInteractionEndRef = useRef(onInteractionEnd);
+ const onExpandedChangeRef = useRef(onExpandedChange);
+ const expansionIsControlledRef = useRef(expansionIsControlled);
+ const controlledExpandedRef = useRef(controlledExpanded);
+ useLayoutEffect(() => {
+ onZoomChangeRef.current = onZoomChange;
+ onInteractionEndRef.current = onInteractionEnd;
+ onExpandedChangeRef.current = onExpandedChange;
+ expansionIsControlledRef.current = expansionIsControlled;
+ controlledExpandedRef.current = controlledExpanded;
+ expandedRef.current = resolvedExpanded;
+ });
+
+ const requestExpanded = useCallback(
+ (nextExpanded: boolean) => {
+ if (collapseTimerRef.current) {
+ clearTimeout(collapseTimerRef.current);
+ collapseTimerRef.current = null;
+ }
+ if (expansionIsControlledRef.current) {
+ if (controlledExpandedRef.current === nextExpanded) {
+ // Keep the shared visual state aligned with the controlled prop.
+ expansionProgress.set(
+ withTiming(nextExpanded ? 1 : 0, {
+ duration: 170,
+ easing: EASE_OUT,
+ reduceMotion: ReduceMotion.System,
+ })
+ );
+ return;
+ }
+ onExpandedChangeRef.current?.(nextExpanded);
+ return;
+ }
+ if (expandedRef.current === nextExpanded) return;
+ expandedRef.current = nextExpanded;
+ setUncontrolledExpanded(nextExpanded);
+ expansionProgress.set(
+ withTiming(nextExpanded ? 1 : 0, {
+ duration: 170,
+ easing: EASE_OUT,
+ reduceMotion: ReduceMotion.System,
+ })
+ );
+ onExpandedChangeRef.current?.(nextExpanded);
+ },
+ [expansionProgress]
+ );
+
+ const handleRequestExpanded = useCallback(() => {
+ requestExpanded(true);
+ }, [requestExpanded]);
+
+ const handleZoomChange = useCallback(
+ (nextZoom: number) => {
+ if (!zoomIsControlled) setUncontrolledZoom(nextZoom);
+ onZoomChangeRef.current?.(nextZoom);
+ },
+ [setUncontrolledZoom, zoomIsControlled]
+ );
+
+ const handleInteractionEnd = useCallback(
+ (finalZoom: number) => {
+ onInteractionEndRef.current?.(finalZoom);
+ if (collapseTimerRef.current) clearTimeout(collapseTimerRef.current);
+ collapseTimerRef.current = setTimeout(() => {
+ requestExpanded(false);
+ collapseTimerRef.current = null;
+ }, CAMERA_ZOOM_DIAL_GEOMETRY.COLLAPSE_DELAY_MS);
+ },
+ [requestExpanded]
+ );
+
+ useEffect(
+ () => () => {
+ if (collapseTimerRef.current) clearTimeout(collapseTimerRef.current);
+ },
+ []
+ );
+
+ useEffect(() => {
+ expansionProgress.set(
+ withTiming(resolvedExpanded ? 1 : 0, {
+ duration: 170,
+ easing: EASE_OUT,
+ reduceMotion: ReduceMotion.System,
+ })
+ );
+ }, [expansionProgress, resolvedExpanded]);
+
+ const diameter = layoutWidth * CAMERA_ZOOM_DIAL_GEOMETRY.DIAMETER_TO_WIDTH;
+ const radius = diameter / 2;
+ const unit =
+ layoutWidth > 0 ? Math.max(0.75, Math.min(1.5, layoutWidth / 393)) : 1;
+
+ const { displayZoom, gesture, moveToZoom, rotationAnimatedProps } =
+ useCameraZoomMotion({
+ zoom: resolvedZoom,
+ minZoom: range.min,
+ maxZoom: range.max,
+ step,
+ radius,
+ enabled: !disabled && layoutWidth > 0,
+ expanded: resolvedExpanded,
+ onZoomChange: handleZoomChange,
+ onInteractionStart,
+ onInteractionEnd: handleInteractionEnd,
+ onRequestExpanded: handleRequestExpanded,
+ });
+
+ const ticks = useMemo(
+ () => buildCameraZoomTicks(range.min, range.max, step),
+ [range.max, range.min, step]
+ );
+ const wheelLabels = useMemo(() => {
+ const candidates: CameraZoomStop[] = [
+ ...zoomStops,
+ { value: range.min },
+ { value: range.max },
+ ];
+ return normalizeCameraZoomStops(
+ candidates,
+ range.min,
+ range.max,
+ step
+ );
+ }, [range.max, range.min, step, zoomStops]);
+ const activeStop = findCameraZoomStop(displayZoom, zoomStops, step);
+ const formattedValue =
+ formatValue?.(displayZoom) ?? formatCameraZoomValue(displayZoom, step);
+
+ const handleLayout = useCallback((event: LayoutChangeEvent) => {
+ const nextWidth = event.nativeEvent.layout.width;
+ setLayoutWidth((current) => (current === nextWidth ? current : nextWidth));
+ }, []);
+
+ const handleAccessibilityAction = useCallback(
+ (event: AccessibilityActionEvent) => {
+ if (event.nativeEvent.actionName === 'increment') {
+ moveToZoom(displayZoom + step);
+ }
+ if (event.nativeEvent.actionName === 'decrement') {
+ moveToZoom(displayZoom - step);
+ }
+ },
+ [displayZoom, moveToZoom, step]
+ );
+
+ const handleCompactStopPress = useCallback(
+ (stopValue: number, selected: boolean) => {
+ if (selected) {
+ requestExpanded(true);
+ return;
+ }
+ moveToZoom(stopValue);
+ },
+ [moveToZoom, requestExpanded]
+ );
+
+ const wheelRevealStyle = useAnimatedStyle(() => {
+ const progress = expansionProgress.get();
+ return {
+ opacity: progress,
+ transform: [
+ {
+ scale: reducedMotion ? 1 : interpolate(progress, [0, 1], [0.46, 1]),
+ },
+ ],
+ };
+ }, [reducedMotion]);
+ const compactStyle = useAnimatedStyle(() => {
+ const progress = expansionProgress.get();
+ return {
+ opacity: 1 - progress,
+ transform: [
+ {
+ scale: reducedMotion ? 1 : interpolate(progress, [0, 1], [1, 0.94]),
+ },
+ ],
+ };
+ }, [reducedMotion]);
+
+ const outerTickRadius =
+ radius - CAMERA_ZOOM_DIAL_GEOMETRY.OUTER_TICK_INSET * unit;
+ const pointerTop = CAMERA_ZOOM_DIAL_GEOMETRY.OUTER_TICK_INSET * unit;
+ const pointerHalfWidth = CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_HALF_WIDTH * unit;
+ const pointerHeight = CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_HEIGHT * unit;
+ const pointerOcclusionPadding =
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_OCCLUSION_PADDING * unit;
+ const pointerOcclusionTop = Math.max(0, pointerTop - pointerOcclusionPadding);
+ const pointerOcclusionApex =
+ pointerTop +
+ pointerHeight +
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_OCCLUSION_APEX_OFFSET * unit;
+ const pointerOcclusionEnd =
+ pointerTop +
+ CAMERA_ZOOM_DIAL_GEOMETRY.MAJOR_TICK_LENGTH * unit +
+ pointerOcclusionPadding;
+ const pointerOcclusionHalfWidth = getCameraZoomTriangleHalfWidth(
+ pointerOcclusionApex - pointerOcclusionTop,
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_OCCLUSION_APEX_ANGLE
+ );
+ const pointerOcclusionTailHalfWidth =
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_OCCLUSION_TAIL_HALF_WIDTH * unit;
+ const pointerOcclusionTailTop =
+ pointerOcclusionApex - pointerOcclusionTailHalfWidth;
+ const pointerOcclusionPath = getCameraZoomRoundedTrianglePath(
+ radius,
+ pointerOcclusionTop,
+ pointerOcclusionHalfWidth,
+ pointerOcclusionApex,
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_OCCLUSION_CORNER_RADIUS * unit
+ );
+ const pointerPath = getCameraZoomRoundedTrianglePath(
+ radius,
+ pointerTop,
+ pointerHalfWidth,
+ pointerTop + pointerHeight,
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_CORNER_RADIUS * unit
+ );
+ const compactSelectedStop = findNearestCameraZoomStop(displayZoom, zoomStops);
+
+ return (
+
+
+
+ {layoutWidth > 0 ? (
+
+
+
+
+
+ {formattedValue}
+
+ {activeStop?.focalLength ? (
+
+ {activeStop.focalLength}
+
+ ) : null}
+
+
+ ) : null}
+
+
+ {zoomStops.map((stop) => {
+ const selected = compactSelectedStop?.value === stop.value;
+ const label = selected
+ ? (formatValue?.(displayZoom) ??
+ formatCameraZoomValue(displayZoom, step))
+ : (stop.compactLabel ??
+ stop.label ??
+ formatCameraZoomCompactNumber(stop.value, step));
+ return (
+ handleCompactStopPress(stop.value, selected)}
+ testID={testID ? `${testID}-stop-${stop.value}` : undefined}
+ style={({ pressed }) => [
+ styles.compactButton,
+ {
+ width: 42 * unit,
+ height: 42 * unit,
+ transform: [{ scale: pressed ? 0.97 : 1 }],
+ },
+ selected && {
+ backgroundColor: CAMERA_ZOOM_DIAL_COLORS.COMPACT_SURFACE,
+ borderRadius: 21 * unit,
+ },
+ ]}
+ >
+
+ {label}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ gestureRoot: {
+ alignSelf: 'stretch',
+ aspectRatio: 1 / CAMERA_ZOOM_DIAL_GEOMETRY.VISIBLE_HEIGHT_TO_WIDTH,
+ overflow: 'hidden',
+ },
+ container: {
+ flex: 1,
+ overflow: 'hidden',
+ },
+ wheelLayer: {
+ position: 'absolute',
+ top: 0,
+ },
+ currentLabel: {
+ position: 'absolute',
+ alignItems: 'center',
+ },
+ currentValue: {
+ fontVariant: ['tabular-nums'],
+ fontWeight: '400',
+ textAlign: 'center',
+ },
+ currentFocalLength: {
+ fontVariant: ['tabular-nums'],
+ fontWeight: '400',
+ letterSpacing: 0.35,
+ textAlign: 'center',
+ },
+ compactRow: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ alignItems: 'center',
+ flexDirection: 'row',
+ justifyContent: 'center',
+ },
+ compactButton: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ compactLabel: {
+ fontVariant: ['tabular-nums'],
+ fontWeight: '500',
+ textAlign: 'center',
+ },
+});
diff --git a/packages/dial-slider/src/components/camera-zoom-dial/constants.ts b/packages/dial-slider/src/components/camera-zoom-dial/constants.ts
new file mode 100644
index 0000000..79bb9d1
--- /dev/null
+++ b/packages/dial-slider/src/components/camera-zoom-dial/constants.ts
@@ -0,0 +1,42 @@
+export const CAMERA_ZOOM_DIAL_GEOMETRY = {
+ /** Measured from the supplied 1180 px-wide iPhone recording. */
+ DIAMETER_TO_WIDTH: 1.104,
+ /** (2556 - 1551.6) / 1180: visible circle from its top to screen bottom. */
+ VISIBLE_HEIGHT_TO_WIDTH: 0.852,
+ DEGREES_PER_OCTAVE: 20,
+ MINOR_TICK_LENGTH: 15,
+ /** Major ticks use color and stroke width—not height—for emphasis. */
+ MAJOR_TICK_LENGTH: 15,
+ OUTER_TICK_INSET: 5,
+ LABEL_INSET: 42,
+ FOCAL_LABEL_INSET: 67,
+ /** Measured at roughly 12×27 px in the 3× iPhone capture. */
+ POINTER_HALF_WIDTH: 2,
+ POINTER_HEIGHT: 9,
+ /** Subtle rounding measured from the softened yellow marker in the capture. */
+ POINTER_CORNER_RADIUS: 0.45,
+ /** Extra clearance prevents anti-aliased tick edges bleeding through. */
+ POINTER_OCCLUSION_PADDING: 1,
+ /** The 90° cutout begins just below the yellow pointer's tip. */
+ POINTER_OCCLUSION_APEX_OFFSET: 2,
+ /** A narrow stem keeps the selected stroke hidden below the V-shaped cutout. */
+ POINTER_OCCLUSION_TAIL_HALF_WIDTH: 1.5,
+ /** The reference cutout is an isosceles triangle with a right-angle tip. */
+ POINTER_OCCLUSION_APEX_ANGLE: 90,
+ /** Softens all three mask corners without moving its conceptual anchor. */
+ POINTER_OCCLUSION_CORNER_RADIUS: 1.25,
+ /** Compact controls begin about 207 physical px below the circle top at 3x. */
+ COMPACT_ROW_TOP: 69,
+ LONG_PRESS_MS: 220,
+ COLLAPSE_DELAY_MS: 1_200,
+} as const;
+
+export const CAMERA_ZOOM_DIAL_COLORS = {
+ ACCENT: '#FFD60A',
+ SURFACE: 'rgba(0, 0, 0, 0.5)',
+ LABEL: '#FFFFFF',
+ SECONDARY_LABEL: 'rgba(255, 255, 255, 0.32)',
+ MINOR_TICK: 'rgba(255, 255, 255, 0.25)',
+ MAJOR_TICK: 'rgba(255, 255, 255, 0.82)',
+ COMPACT_SURFACE: 'rgba(28, 28, 30, 0.68)',
+} as const;
diff --git a/packages/dial-slider/src/components/camera-zoom-dial/types.ts b/packages/dial-slider/src/components/camera-zoom-dial/types.ts
new file mode 100644
index 0000000..45be739
--- /dev/null
+++ b/packages/dial-slider/src/components/camera-zoom-dial/types.ts
@@ -0,0 +1,50 @@
+import type { StyleProp, ViewStyle } from 'react-native';
+
+export interface CameraZoomStop {
+ /** Positive zoom factor; normalized to the nearest configured step. */
+ value: number;
+ /** Circular wheel label. Defaults to the numeric zoom factor without `x`. */
+ label?: string;
+ /** Compact inactive label. Defaults to `label`, or camera-style numeric text. */
+ compactLabel?: string;
+ /** Optional equivalent focal length, for example `13MM` or `26MM`. */
+ focalLength?: string;
+}
+
+export interface CameraZoomDialProps {
+ /** Smallest supported zoom factor. Must be positive. Defaults to `0.5`. */
+ minZoom?: number;
+ /** Largest supported zoom factor. Must exceed minZoom. Defaults to `10`. */
+ maxZoom?: number;
+ /** Display/callback precision. Defaults to `0.1`. */
+ step?: number;
+ /** Controlled zoom factor. */
+ value?: number;
+ /** Initial uncontrolled zoom factor. Defaults to `1` when it is in range. */
+ defaultValue?: number;
+ /**
+ * Optical or quick-zoom stops shown in the compact control and on the wheel.
+ * Empty or entirely invalid input falls back to the default in-range stops.
+ */
+ zoomStops?: readonly CameraZoomStop[];
+ /** Controlled expanded state. */
+ expanded?: boolean;
+ /** Initial expanded state for uncontrolled use. Defaults to `false`. */
+ defaultExpanded?: boolean;
+ /** Reports compact/wheel state requests. */
+ onExpandedChange?: (expanded: boolean) => void;
+ /** Reports each crossed zoom step while dragging or animating. */
+ onZoomChange?: (zoom: number) => void;
+ onInteractionStart?: () => void;
+ /** Reports the final snapped zoom factor after a drag. */
+ onInteractionEnd?: (zoom: number) => void;
+ formatValue?: (zoom: number) => string;
+ accentColor?: string;
+ surfaceColor?: string;
+ labelColor?: string;
+ disabled?: boolean;
+ accessibilityLabel?: string;
+ accessibilityHint?: string;
+ style?: StyleProp;
+ testID?: string;
+}
diff --git a/packages/dial-slider/src/hooks/useCameraZoomMotion.ts b/packages/dial-slider/src/hooks/useCameraZoomMotion.ts
new file mode 100644
index 0000000..21f4b5a
--- /dev/null
+++ b/packages/dial-slider/src/hooks/useCameraZoomMotion.ts
@@ -0,0 +1,295 @@
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { Gesture } from 'react-native-gesture-handler';
+import {
+ cancelAnimation,
+ Easing,
+ ReduceMotion,
+ useAnimatedProps,
+ useAnimatedReaction,
+ useSharedValue,
+ withSpring,
+ withTiming,
+} from 'react-native-reanimated';
+import { scheduleOnRN } from 'react-native-worklets';
+
+import { CAMERA_ZOOM_DIAL_GEOMETRY } from '../components/camera-zoom-dial/constants';
+import {
+ cameraLogPositionToZoom,
+ cameraZoomFromTranslation,
+ cameraZoomRotationMatrix,
+ cameraZoomToLogPosition,
+ clampCameraZoom,
+ roundCameraZoom,
+} from '../utils/camera-zoom-dial/camera-zoom-math';
+
+const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1);
+
+// Reanimated parses animated `transform` updates as RN/CSS transforms. Forward
+// the six-value SVG affine matrix to react-native-svg before that parser runs.
+function adaptSvgTransformToMatrix(props: Record) {
+ 'worklet';
+ if (!Array.isArray(props.transform)) return;
+ props.matrix = props.transform;
+ delete props.transform;
+}
+
+interface UseCameraZoomMotionOptions {
+ zoom: number;
+ minZoom: number;
+ maxZoom: number;
+ step: number;
+ radius: number;
+ enabled: boolean;
+ expanded: boolean;
+ onZoomChange?: (zoom: number) => void;
+ onInteractionStart?: () => void;
+ onInteractionEnd?: (zoom: number) => void;
+ onRequestExpanded?: () => void;
+}
+
+export function useCameraZoomMotion({
+ zoom,
+ minZoom,
+ maxZoom,
+ step,
+ radius,
+ enabled,
+ expanded,
+ onZoomChange,
+ onInteractionStart,
+ onInteractionEnd,
+ onRequestExpanded,
+}: UseCameraZoomMotionOptions) {
+ const initialZoom = roundCameraZoom(zoom, step, minZoom, maxZoom);
+ const [displayZoom, setDisplayZoom] = useState(initialZoom);
+ const [isInteracting, setIsInteracting] = useState(false);
+
+ const logZoom = useSharedValue(cameraZoomToLogPosition(initialZoom));
+ const startZoom = useSharedValue(initialZoom);
+ const interactionActive = useSharedValue(0);
+ const lastReportedZoom = useSharedValue(initialZoom);
+ const lastReportedZoomRef = useRef(initialZoom);
+
+ const onZoomChangeRef = useRef(onZoomChange);
+ const onInteractionStartRef = useRef(onInteractionStart);
+ const onInteractionEndRef = useRef(onInteractionEnd);
+ const onRequestExpandedRef = useRef(onRequestExpanded);
+
+ useLayoutEffect(() => {
+ onZoomChangeRef.current = onZoomChange;
+ onInteractionStartRef.current = onInteractionStart;
+ onInteractionEndRef.current = onInteractionEnd;
+ onRequestExpandedRef.current = onRequestExpanded;
+ });
+
+ const reportZoom = useCallback((nextZoom: number) => {
+ setDisplayZoom(nextZoom);
+ if (lastReportedZoomRef.current === nextZoom) return;
+ lastReportedZoomRef.current = nextZoom;
+ onZoomChangeRef.current?.(nextZoom);
+ }, []);
+
+ const beginInteraction = useCallback(() => {
+ setIsInteracting(true);
+ onRequestExpandedRef.current?.();
+ onInteractionStartRef.current?.();
+ }, []);
+
+ const finishInteraction = useCallback((finalZoom: number) => {
+ setDisplayZoom(finalZoom);
+ setIsInteracting(false);
+ onInteractionEndRef.current?.(finalZoom);
+ }, []);
+
+ useAnimatedReaction(
+ () => ({
+ active: interactionActive.get(),
+ zoom: roundCameraZoom(
+ cameraLogPositionToZoom(logZoom.get()),
+ step,
+ minZoom,
+ maxZoom
+ ),
+ }),
+ (current, previous) => {
+ if (
+ current.active === 1 &&
+ previous !== null &&
+ current.zoom !== previous.zoom &&
+ current.zoom !== lastReportedZoom.get()
+ ) {
+ lastReportedZoom.set(current.zoom);
+ scheduleOnRN(reportZoom, current.zoom);
+ }
+ },
+ [interactionActive, maxZoom, minZoom, step]
+ );
+
+ useEffect(() => {
+ if (isInteracting || interactionActive.get() === 1) return;
+ const next = roundCameraZoom(zoom, step, minZoom, maxZoom);
+ lastReportedZoom.set(next);
+ lastReportedZoomRef.current = next;
+ // Prop-driven synchronization intentionally updates the visible label.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setDisplayZoom(next);
+ logZoom.set(
+ withTiming(cameraZoomToLogPosition(next), {
+ duration: 180,
+ easing: EASE_OUT,
+ reduceMotion: ReduceMotion.System,
+ })
+ );
+ }, [
+ interactionActive,
+ isInteracting,
+ lastReportedZoom,
+ logZoom,
+ maxZoom,
+ minZoom,
+ step,
+ zoom,
+ ]);
+
+ const gesture = useMemo(() => {
+ let pan = Gesture.Pan()
+ .enabled(enabled)
+ .minDistance(1)
+ .onStart(() => {
+ cancelAnimation(logZoom);
+ startZoom.set(cameraLogPositionToZoom(logZoom.get()));
+ interactionActive.set(1);
+ scheduleOnRN(beginInteraction);
+ })
+ .onUpdate((event) => {
+ const next = cameraZoomFromTranslation(
+ startZoom.get(),
+ event.translationX,
+ radius,
+ minZoom,
+ maxZoom,
+ CAMERA_ZOOM_DIAL_GEOMETRY.DEGREES_PER_OCTAVE
+ );
+ logZoom.set(cameraZoomToLogPosition(next));
+ })
+ .onFinalize((event) => {
+ if (interactionActive.get() === 0) return;
+ const finalZoom = roundCameraZoom(
+ cameraLogPositionToZoom(logZoom.get()),
+ step,
+ minZoom,
+ maxZoom
+ );
+ const pointsPerOctave = Math.max(
+ 1,
+ radius *
+ ((CAMERA_ZOOM_DIAL_GEOMETRY.DEGREES_PER_OCTAVE * Math.PI) / 180)
+ );
+ const velocity = -event.velocityX / pointsPerOctave;
+ interactionActive.set(0);
+ lastReportedZoom.set(finalZoom);
+ logZoom.set(
+ withSpring(cameraZoomToLogPosition(finalZoom), {
+ duration: 400,
+ dampingRatio: 1,
+ velocity,
+ overshootClamping: true,
+ reduceMotion: ReduceMotion.System,
+ })
+ );
+ scheduleOnRN(reportZoom, finalZoom);
+ scheduleOnRN(finishInteraction, finalZoom);
+ });
+
+ if (!expanded) {
+ pan = pan.activateAfterLongPress(CAMERA_ZOOM_DIAL_GEOMETRY.LONG_PRESS_MS);
+ }
+ return pan;
+ }, [
+ beginInteraction,
+ enabled,
+ expanded,
+ finishInteraction,
+ interactionActive,
+ lastReportedZoom,
+ logZoom,
+ maxZoom,
+ minZoom,
+ radius,
+ reportZoom,
+ startZoom,
+ step,
+ ]);
+
+ const rotationAnimatedProps = useAnimatedProps(
+ () => ({
+ transform: cameraZoomRotationMatrix(
+ -logZoom.get() * CAMERA_ZOOM_DIAL_GEOMETRY.DEGREES_PER_OCTAVE,
+ radius
+ ),
+ }),
+ [radius],
+ adaptSvgTransformToMatrix
+ );
+
+ const moveToZoom = useCallback(
+ (requestedZoom: number) => {
+ if (!enabled) return;
+ const next = roundCameraZoom(
+ clampCameraZoom(requestedZoom, minZoom, maxZoom),
+ step,
+ minZoom,
+ maxZoom
+ );
+ if (next === displayZoom) return;
+ cancelAnimation(logZoom);
+ interactionActive.set(1);
+ lastReportedZoom.set(displayZoom);
+ setIsInteracting(true);
+ onInteractionStartRef.current?.();
+ logZoom.set(
+ withTiming(
+ cameraZoomToLogPosition(next),
+ {
+ duration: 180,
+ easing: EASE_OUT,
+ reduceMotion: ReduceMotion.System,
+ },
+ (finished) => {
+ if (!finished) return;
+ interactionActive.set(0);
+ lastReportedZoom.set(next);
+ scheduleOnRN(reportZoom, next);
+ scheduleOnRN(finishInteraction, next);
+ }
+ )
+ );
+ },
+ [
+ displayZoom,
+ enabled,
+ finishInteraction,
+ interactionActive,
+ lastReportedZoom,
+ logZoom,
+ maxZoom,
+ minZoom,
+ reportZoom,
+ step,
+ ]
+ );
+
+ return {
+ displayZoom,
+ gesture,
+ moveToZoom,
+ rotationAnimatedProps,
+ };
+}
diff --git a/packages/dial-slider/src/index.ts b/packages/dial-slider/src/index.ts
index a908a38..f596c31 100644
--- a/packages/dial-slider/src/index.ts
+++ b/packages/dial-slider/src/index.ts
@@ -1,3 +1,8 @@
+export { CameraZoomDial } from './components/camera-zoom-dial/CameraZoomDial';
+export type {
+ CameraZoomDialProps,
+ CameraZoomStop,
+} from './components/camera-zoom-dial/types';
export { DialSlider } from './components/dial-slider/DialSlider';
export type {
DialPreset,
diff --git a/packages/dial-slider/src/utils/camera-zoom-dial/camera-zoom-math.ts b/packages/dial-slider/src/utils/camera-zoom-dial/camera-zoom-math.ts
new file mode 100644
index 0000000..2387b6f
--- /dev/null
+++ b/packages/dial-slider/src/utils/camera-zoom-dial/camera-zoom-math.ts
@@ -0,0 +1,377 @@
+export interface CameraZoomRange {
+ min: number;
+ max: number;
+}
+
+export interface CameraZoomStopValue {
+ value: number;
+}
+
+export type CameraZoomRotationMatrix = [
+ number,
+ number,
+ number,
+ number,
+ number,
+ number,
+];
+
+export const CAMERA_ZOOM_DEFAULTS = {
+ MIN: 0.5,
+ MAX: 10,
+ STEP: 0.1,
+ DEGREES_PER_OCTAVE: 20,
+ MAX_TICK_COUNT: 240,
+} as const;
+
+const MAX_ZOOM_FACTOR = 1_000;
+const FLOAT_PRECISION = 1_000_000;
+
+function clampFinitePositive(value: number, fallback: number) {
+ 'worklet';
+ const resolved = Number.isFinite(value) && value > 0 ? value : fallback;
+ const clamped = Math.min(
+ MAX_ZOOM_FACTOR,
+ Math.max(1 / FLOAT_PRECISION, resolved)
+ );
+ return Math.round(clamped * FLOAT_PRECISION) / FLOAT_PRECISION;
+}
+
+export function normalizeCameraZoomRange(
+ minZoom: number = CAMERA_ZOOM_DEFAULTS.MIN,
+ maxZoom: number = CAMERA_ZOOM_DEFAULTS.MAX
+): CameraZoomRange {
+ const first = clampFinitePositive(minZoom, CAMERA_ZOOM_DEFAULTS.MIN);
+ const second = clampFinitePositive(maxZoom, CAMERA_ZOOM_DEFAULTS.MAX);
+ const min = Math.min(first, second);
+ const max = Math.max(first, second);
+
+ if (min === max) {
+ return {
+ min: CAMERA_ZOOM_DEFAULTS.MIN,
+ max: CAMERA_ZOOM_DEFAULTS.MAX,
+ };
+ }
+ return { min, max };
+}
+
+export function getSafeCameraZoomStep(step: number) {
+ 'worklet';
+ return clampFinitePositive(step, CAMERA_ZOOM_DEFAULTS.STEP);
+}
+
+function getCameraZoomBounds(minZoom: number, maxZoom: number) {
+ 'worklet';
+ const first = clampFinitePositive(minZoom, CAMERA_ZOOM_DEFAULTS.MIN);
+ const second = clampFinitePositive(maxZoom, CAMERA_ZOOM_DEFAULTS.MAX);
+ return {
+ min: Math.min(first, second),
+ max: Math.max(first, second),
+ };
+}
+
+export function clampCameraZoom(
+ zoom: number,
+ minZoom: number,
+ maxZoom: number
+) {
+ 'worklet';
+ const { min, max } = getCameraZoomBounds(minZoom, maxZoom);
+ const safeZoom = Number.isFinite(zoom) && zoom > 0 ? zoom : min;
+ return Math.min(max, Math.max(min, safeZoom));
+}
+
+export function roundCameraZoom(
+ zoom: number,
+ step: number,
+ minZoom: number,
+ maxZoom: number
+) {
+ 'worklet';
+ const safeStep = getSafeCameraZoomStep(step);
+ const { min, max } = getCameraZoomBounds(minZoom, maxZoom);
+ const clamped = clampCameraZoom(zoom, minZoom, maxZoom);
+ const rounded =
+ clamped === min || clamped === max
+ ? clamped
+ : Math.round(clamped / safeStep) * safeStep;
+ return (
+ Math.round(clampCameraZoom(rounded, minZoom, maxZoom) * FLOAT_PRECISION) /
+ FLOAT_PRECISION
+ );
+}
+
+export function cameraZoomToLogPosition(zoom: number) {
+ 'worklet';
+ const safeZoom =
+ Number.isFinite(zoom) && zoom > 0 ? zoom : CAMERA_ZOOM_DEFAULTS.MIN;
+ return Math.log2(safeZoom);
+}
+
+export function cameraLogPositionToZoom(position: number) {
+ 'worklet';
+ return Math.pow(2, Number.isFinite(position) ? position : 0);
+}
+
+/**
+ * The reference video spaces each doubling (0.5→1→2) by approximately 20°.
+ */
+export function cameraZoomToAngle(
+ zoom: number,
+ selectedZoom: number,
+ degreesPerOctave: number = CAMERA_ZOOM_DEFAULTS.DEGREES_PER_OCTAVE
+) {
+ 'worklet';
+ const safeDegrees =
+ Number.isFinite(degreesPerOctave) && degreesPerOctave > 0
+ ? degreesPerOctave
+ : CAMERA_ZOOM_DEFAULTS.DEGREES_PER_OCTAVE;
+ return (
+ (cameraZoomToLogPosition(zoom) - cameraZoomToLogPosition(selectedZoom)) *
+ safeDegrees
+ );
+}
+
+/** Builds the native SVG affine matrix for a rotation around a fixed point. */
+export function cameraZoomRotationMatrix(
+ angleDegrees: number,
+ centerX: number,
+ centerY: number = centerX
+): CameraZoomRotationMatrix {
+ 'worklet';
+ const safeAngle = Number.isFinite(angleDegrees) ? angleDegrees : 0;
+ const safeCenterX = Number.isFinite(centerX) ? centerX : 0;
+ const safeCenterY = Number.isFinite(centerY) ? centerY : 0;
+ const radians = (safeAngle * Math.PI) / 180;
+ const cosine = Math.cos(radians);
+ const sine = Math.sin(radians);
+
+ return [
+ cosine,
+ sine,
+ -sine,
+ cosine,
+ safeCenterX - safeCenterX * cosine + safeCenterY * sine,
+ safeCenterY - safeCenterX * sine - safeCenterY * cosine,
+ ];
+}
+
+/** Returns one half of an isosceles triangle's base for its tip angle. */
+export function getCameraZoomTriangleHalfWidth(
+ height: number,
+ apexAngleDegrees: number
+) {
+ const safeHeight = Number.isFinite(height) ? Math.max(0, height) : 0;
+ const safeAngle =
+ Number.isFinite(apexAngleDegrees) &&
+ apexAngleDegrees > 0 &&
+ apexAngleDegrees < 180
+ ? apexAngleDegrees
+ : 90;
+ return safeHeight * Math.tan((safeAngle * Math.PI) / 360);
+}
+
+/** Builds a closed triangular SVG path with a quadratic curve at every corner. */
+export function getCameraZoomRoundedTrianglePath(
+ centerX: number,
+ topY: number,
+ halfWidth: number,
+ apexY: number,
+ cornerRadius: number
+) {
+ const safeCenterX = Number.isFinite(centerX) ? centerX : 0;
+ const safeTopY = Number.isFinite(topY) ? topY : 0;
+ const safeHalfWidth = Number.isFinite(halfWidth) ? Math.max(0, halfWidth) : 0;
+ const safeApexY = Number.isFinite(apexY)
+ ? Math.max(safeTopY, apexY)
+ : safeTopY;
+ const height = safeApexY - safeTopY;
+ const leftX = safeCenterX - safeHalfWidth;
+ const rightX = safeCenterX + safeHalfWidth;
+ const sharpPath = `M ${leftX} ${safeTopY} L ${rightX} ${safeTopY} L ${safeCenterX} ${safeApexY} Z`;
+ const requestedRadius = Number.isFinite(cornerRadius)
+ ? Math.max(0, cornerRadius)
+ : 0;
+
+ if (requestedRadius === 0 || safeHalfWidth === 0 || height === 0) {
+ return sharpPath;
+ }
+
+ const sideLength = Math.hypot(safeHalfWidth, height);
+ const baseAngle = Math.atan2(height, safeHalfWidth);
+ const apexAngle = Math.PI - 2 * baseAngle;
+ const baseTangent = Math.tan(baseAngle / 2);
+ const apexTangent = Math.tan(apexAngle / 2);
+
+ if (baseTangent <= 0 || apexTangent <= 0) return sharpPath;
+
+ const maxBaseRadius = safeHalfWidth * baseTangent;
+ const maxSideRadius = sideLength / (1 / baseTangent + 1 / apexTangent);
+ const radius = Math.min(requestedRadius, maxBaseRadius, maxSideRadius);
+ const baseOffset = radius / baseTangent;
+ const apexOffset = radius / apexTangent;
+ const sideUnitX = safeHalfWidth / sideLength;
+ const sideUnitY = height / sideLength;
+ const baseSideX = sideUnitX * baseOffset;
+ const baseSideY = sideUnitY * baseOffset;
+ const apexSideX = sideUnitX * apexOffset;
+ const apexSideY = sideUnitY * apexOffset;
+
+ return [
+ `M ${leftX + baseOffset} ${safeTopY}`,
+ `L ${rightX - baseOffset} ${safeTopY}`,
+ `Q ${rightX} ${safeTopY} ${rightX - baseSideX} ${safeTopY + baseSideY}`,
+ `L ${safeCenterX + apexSideX} ${safeApexY - apexSideY}`,
+ `Q ${safeCenterX} ${safeApexY} ${safeCenterX - apexSideX} ${safeApexY - apexSideY}`,
+ `L ${leftX + baseSideX} ${safeTopY + baseSideY}`,
+ `Q ${leftX} ${safeTopY} ${leftX + baseOffset} ${safeTopY}`,
+ 'Z',
+ ].join(' ');
+}
+
+/** Maps a horizontal drag to the tangential rotation of the circular scale. */
+export function cameraZoomFromTranslation(
+ startZoom: number,
+ translationX: number,
+ radius: number,
+ minZoom: number,
+ maxZoom: number,
+ degreesPerOctave: number = CAMERA_ZOOM_DEFAULTS.DEGREES_PER_OCTAVE
+) {
+ 'worklet';
+ const safeRadius = Number.isFinite(radius) && radius > 0 ? radius : 1;
+ const safeDegrees =
+ Number.isFinite(degreesPerOctave) && degreesPerOctave > 0
+ ? degreesPerOctave
+ : CAMERA_ZOOM_DEFAULTS.DEGREES_PER_OCTAVE;
+ const pointsPerOctave = safeRadius * ((safeDegrees * Math.PI) / 180);
+ const octaveDelta =
+ -(Number.isFinite(translationX) ? translationX : 0) /
+ Math.max(1, pointsPerOctave);
+ return clampCameraZoom(
+ startZoom * Math.pow(2, octaveDelta),
+ minZoom,
+ maxZoom
+ );
+}
+
+export function buildCameraZoomTicks(
+ minZoom: number,
+ maxZoom: number,
+ step: number,
+ maxTickCount: number = CAMERA_ZOOM_DEFAULTS.MAX_TICK_COUNT
+) {
+ const { min, max } = normalizeCameraZoomRange(minZoom, maxZoom);
+ const baseStep = getSafeCameraZoomStep(step);
+ const safeMaxCount = Math.max(2, Math.floor(maxTickCount));
+ const estimatedCount = Math.floor((max - min) / baseStep) + 1;
+ const tickStep =
+ baseStep * Math.max(1, Math.ceil(estimatedCount / safeMaxCount));
+ const firstAligned = Math.ceil(min / tickStep) * tickStep;
+ const epsilon = tickStep / FLOAT_PRECISION;
+ const ticks = [min];
+
+ for (let value = firstAligned; value < max - epsilon; value += tickStep) {
+ const rounded = Math.round(value * FLOAT_PRECISION) / FLOAT_PRECISION;
+ if (rounded > min + epsilon) ticks.push(rounded);
+ if (ticks.length >= safeMaxCount - 1) break;
+ }
+
+ if (Math.abs(ticks[ticks.length - 1] - max) > epsilon) ticks.push(max);
+ return ticks;
+}
+
+export function isCameraZoomMajorTick(
+ zoom: number,
+ stops: readonly CameraZoomStopValue[],
+ step: number,
+ minZoom: number,
+ maxZoom: number
+) {
+ const tolerance = Math.max(getSafeCameraZoomStep(step) / 100, 1e-6);
+ return (
+ Math.abs(zoom - minZoom) <= tolerance ||
+ Math.abs(zoom - maxZoom) <= tolerance ||
+ Math.abs(zoom - Math.round(zoom)) <= tolerance ||
+ stops.some((stop) => Math.abs(stop.value - zoom) <= tolerance)
+ );
+}
+
+export function normalizeCameraZoomStops(
+ stops: readonly T[],
+ minZoom: number,
+ maxZoom: number,
+ step?: number
+) {
+ const { min, max } = normalizeCameraZoomRange(minZoom, maxZoom);
+ const seen = new Set();
+ return stops
+ .filter(
+ (stop) =>
+ Number.isFinite(stop.value) && stop.value >= min && stop.value <= max
+ )
+ .map((stop) => {
+ if (step === undefined) return stop;
+ const value = roundCameraZoom(stop.value, step, min, max);
+ return value === stop.value ? stop : ({ ...stop, value } as T);
+ })
+ .filter((stop) => {
+ const key = Math.round(stop.value * FLOAT_PRECISION);
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ })
+ .sort((first, second) => first.value - second.value);
+}
+
+export function findCameraZoomStop(
+ zoom: number,
+ stops: readonly T[],
+ step: number
+) {
+ const tolerance = Math.max(getSafeCameraZoomStep(step) / 2, 1e-6);
+ return stops.find((stop) => Math.abs(stop.value - zoom) <= tolerance);
+}
+
+export function findNearestCameraZoomStop(
+ zoom: number,
+ stops: readonly T[]
+) {
+ const validStops = stops.filter(
+ (stop) => Number.isFinite(stop.value) && stop.value > 0
+ );
+ if (validStops.length === 0) return undefined;
+
+ const zoomPosition = cameraZoomToLogPosition(zoom);
+ return validStops.reduce((nearest, candidate) => {
+ const nearestDistance = Math.abs(
+ cameraZoomToLogPosition(nearest.value) - zoomPosition
+ );
+ const candidateDistance = Math.abs(
+ cameraZoomToLogPosition(candidate.value) - zoomPosition
+ );
+ return candidateDistance < nearestDistance ? candidate : nearest;
+ });
+}
+
+function getCameraZoomPrecision(value: number) {
+ const source = Math.abs(value).toFixed(6).replace(/0+$/, '');
+ const decimalIndex = source.indexOf('.');
+ return decimalIndex === -1 ? 0 : source.length - decimalIndex - 1;
+}
+
+export function formatCameraZoomNumber(zoom: number, step: number) {
+ const precision = Math.max(
+ getCameraZoomPrecision(getSafeCameraZoomStep(step)),
+ getCameraZoomPrecision(zoom)
+ );
+ return Number(zoom.toFixed(precision)).toString();
+}
+
+export function formatCameraZoomCompactNumber(zoom: number, step: number) {
+ return formatCameraZoomNumber(zoom, step).replace(/^0\./, '.');
+}
+
+export function formatCameraZoomValue(zoom: number, step: number) {
+ return `${formatCameraZoomNumber(zoom, step)}x`;
+}
diff --git a/packages/dial-slider/tests/camera-zoom-math.test.ts b/packages/dial-slider/tests/camera-zoom-math.test.ts
new file mode 100644
index 0000000..7c22cb5
--- /dev/null
+++ b/packages/dial-slider/tests/camera-zoom-math.test.ts
@@ -0,0 +1,225 @@
+import { describe, expect, test } from 'bun:test';
+
+import { CAMERA_ZOOM_DIAL_GEOMETRY } from '../src/components/camera-zoom-dial/constants';
+import {
+ buildCameraZoomTicks,
+ CAMERA_ZOOM_DEFAULTS,
+ cameraZoomFromTranslation,
+ cameraZoomRotationMatrix,
+ cameraZoomToAngle,
+ findCameraZoomStop,
+ findNearestCameraZoomStop,
+ formatCameraZoomCompactNumber,
+ formatCameraZoomNumber,
+ formatCameraZoomValue,
+ getCameraZoomRoundedTrianglePath,
+ getCameraZoomTriangleHalfWidth,
+ getSafeCameraZoomStep,
+ isCameraZoomMajorTick,
+ normalizeCameraZoomRange,
+ normalizeCameraZoomStops,
+ roundCameraZoom,
+} from '../src/utils/camera-zoom-dial/camera-zoom-math';
+
+describe('camera zoom range and precision', () => {
+ test('normalizes reversed, non-finite, and zero-width ranges', () => {
+ expect(normalizeCameraZoomRange(10, 0.5)).toEqual({ min: 0.5, max: 10 });
+ expect(
+ normalizeCameraZoomRange(Number.NaN, Number.POSITIVE_INFINITY)
+ ).toEqual({ min: 0.5, max: 10 });
+ expect(normalizeCameraZoomRange(2, 2)).toEqual({ min: 0.5, max: 10 });
+ expect(normalizeCameraZoomRange(0.5500004, 0.9500006)).toEqual({
+ min: 0.55,
+ max: 0.950001,
+ });
+ });
+
+ test('rounds to the requested step and clamps hard camera limits', () => {
+ expect(roundCameraZoom(0.51, 0.1, 0.5, 10)).toBe(0.5);
+ expect(roundCameraZoom(7.36, 0.1, 0.5, 10)).toBe(7.4);
+ expect(roundCameraZoom(12, 0.1, 0.5, 10)).toBe(10);
+ expect(roundCameraZoom(Number.NaN, 0.1, 0.5, 10)).toBe(0.5);
+ expect(getSafeCameraZoomStep(Number.MIN_VALUE)).toBe(0.000001);
+ expect(roundCameraZoom(1, Number.MIN_VALUE, 0.5, 10)).toBe(1);
+ });
+
+ test('keeps unaligned hard limits reachable', () => {
+ expect(roundCameraZoom(0.55, 0.1, 0.55, 0.95)).toBe(0.55);
+ expect(roundCameraZoom(0.95, 0.1, 0.55, 0.95)).toBe(0.95);
+ expect(roundCameraZoom(0.64, 0.1, 0.55, 0.95)).toBe(0.6);
+ expect(formatCameraZoomValue(0.55, 0.1)).toBe('0.55x');
+ expect(formatCameraZoomValue(0.95, 0.1)).toBe('0.95x');
+ expect(roundCameraZoom(0, 0.1, 0.5500004, 0.9500006)).toBe(0.55);
+ expect(roundCameraZoom(2, 0.1, 0.5500004, 0.9500006)).toBe(0.950001);
+ });
+
+ test('formats compact stop labels separately from active zoom labels', () => {
+ expect(formatCameraZoomNumber(0.5, 0.1)).toBe('0.5');
+ expect(formatCameraZoomCompactNumber(0.5, 0.1)).toBe('.5');
+ expect(formatCameraZoomCompactNumber(1, 0.1)).toBe('1');
+ expect(formatCameraZoomValue(1, 0.1)).toBe('1x');
+ expect(formatCameraZoomValue(7.35, 0.05)).toBe('7.35x');
+ });
+});
+
+describe('camera zoom logarithmic wheel geometry', () => {
+ test('preserves the measured pointer and its 90-degree triangular cutout', () => {
+ const pointerWidth = CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_HALF_WIDTH * 2;
+ const cutoutHeight =
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_HEIGHT +
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_OCCLUSION_APEX_OFFSET +
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_OCCLUSION_PADDING;
+ const cutoutHalfWidth = getCameraZoomTriangleHalfWidth(
+ cutoutHeight,
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_OCCLUSION_APEX_ANGLE
+ );
+ const cutoutWidth = cutoutHalfWidth * 2;
+ const apexAngle =
+ (2 * Math.atan(cutoutHalfWidth / cutoutHeight) * 180) / Math.PI;
+ const tailHeight =
+ CAMERA_ZOOM_DIAL_GEOMETRY.MAJOR_TICK_LENGTH +
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_OCCLUSION_PADDING -
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_HEIGHT -
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_OCCLUSION_APEX_OFFSET +
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_OCCLUSION_TAIL_HALF_WIDTH;
+
+ expect(pointerWidth).toBe(4);
+ expect(CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_HEIGHT).toBe(9);
+ expect(cutoutWidth).toBeCloseTo(24);
+ expect(cutoutHeight).toBe(12);
+ expect(apexAngle).toBeCloseTo(90);
+ expect(tailHeight).toBe(6.5);
+ });
+
+ test('keeps major and minor ticks at the same height', () => {
+ expect(CAMERA_ZOOM_DIAL_GEOMETRY.MAJOR_TICK_LENGTH).toBe(
+ CAMERA_ZOOM_DIAL_GEOMETRY.MINOR_TICK_LENGTH
+ );
+ });
+
+ test('rounds every triangle corner while preserving its anchor points', () => {
+ const roundedPath = getCameraZoomRoundedTrianglePath(
+ 0,
+ 0,
+ 12,
+ 12,
+ CAMERA_ZOOM_DIAL_GEOMETRY.POINTER_OCCLUSION_CORNER_RADIUS
+ );
+
+ expect(roundedPath.match(/\bQ\b/g)).toHaveLength(3);
+ expect(roundedPath).toContain('Q 12 0');
+ expect(roundedPath).toContain('Q 0 12');
+ expect(roundedPath).toContain('Q -12 0');
+ expect(getCameraZoomRoundedTrianglePath(0, 0, 12, 12, 0)).toBe(
+ 'M -12 0 L 12 0 L 0 12 Z'
+ );
+ });
+
+ test('spaces every zoom doubling by the measured 20 degree interval', () => {
+ expect(cameraZoomToAngle(0.5, 1)).toBe(-20);
+ expect(cameraZoomToAngle(2, 1)).toBe(20);
+ expect(cameraZoomToAngle(10, 0.5)).toBeCloseTo(86.4386, 4);
+ });
+
+ test('maps one tangential octave of drag to one zoom doubling', () => {
+ const radius = 200;
+ const pointsPerOctave =
+ radius * ((CAMERA_ZOOM_DEFAULTS.DEGREES_PER_OCTAVE * Math.PI) / 180);
+ expect(
+ cameraZoomFromTranslation(1, -pointsPerOctave, radius, 0.5, 10)
+ ).toBeCloseTo(2, 6);
+ expect(
+ cameraZoomFromTranslation(1, pointsPerOctave, radius, 0.5, 10)
+ ).toBeCloseTo(0.5, 6);
+ });
+
+ test('builds a native SVG matrix that rotates around the dial center', () => {
+ const center = 100;
+ const [a, b, c, d, translateX, translateY] = cameraZoomRotationMatrix(
+ 90,
+ center
+ );
+
+ const transformedCenterX = a * center + c * center + translateX;
+ const transformedCenterY = b * center + d * center + translateY;
+ const transformedTopX = a * center + translateX;
+ const transformedTopY = b * center + translateY;
+
+ expect(transformedCenterX).toBeCloseTo(center, 6);
+ expect(transformedCenterY).toBeCloseTo(center, 6);
+ expect(transformedTopX).toBeCloseTo(center * 2, 6);
+ expect(transformedTopY).toBeCloseTo(center, 6);
+ });
+
+ test('builds decimal ticks while capping extreme render work', () => {
+ const ticks = buildCameraZoomTicks(0.5, 10, 0.1);
+ expect(ticks[0]).toBe(0.5);
+ expect(ticks.at(-1)).toBe(10);
+ expect(ticks).toContain(1);
+ expect(ticks).toContain(2);
+ expect(ticks.length).toBeLessThanOrEqual(
+ CAMERA_ZOOM_DEFAULTS.MAX_TICK_COUNT
+ );
+
+ const extremeTicks = buildCameraZoomTicks(0.01, 1_000, 0.001, 120);
+ expect(extremeTicks.length).toBeLessThanOrEqual(120);
+ expect(extremeTicks.every(Number.isFinite)).toBe(true);
+ });
+
+ test('marks integer, range-edge, and optical-stop ticks as major', () => {
+ const stops = [{ value: 0.5 }, { value: 1 }, { value: 2 }];
+ expect(isCameraZoomMajorTick(0.5, stops, 0.1, 0.5, 10)).toBe(true);
+ expect(isCameraZoomMajorTick(3, stops, 0.1, 0.5, 10)).toBe(true);
+ expect(isCameraZoomMajorTick(3.1, stops, 0.1, 0.5, 10)).toBe(false);
+ expect(isCameraZoomMajorTick(10, stops, 0.1, 0.5, 10)).toBe(true);
+ });
+});
+
+describe('camera zoom stops', () => {
+ test('sorts, deduplicates, and drops out-of-range or invalid stops', () => {
+ expect(
+ normalizeCameraZoomStops(
+ [
+ { value: 2, label: 'first' },
+ { value: 0.5 },
+ { value: 2, label: 'duplicate' },
+ { value: Number.NaN },
+ { value: 12 },
+ ],
+ 0.5,
+ 10
+ )
+ ).toEqual([{ value: 0.5 }, { value: 2, label: 'first' }]);
+ });
+
+ test('aligns custom stops with their rendered and selectable step values', () => {
+ expect(
+ normalizeCameraZoomStops(
+ [
+ { value: 1.25, focalLength: '50MM' },
+ { value: 1.26, label: 'duplicate after snapping' },
+ ],
+ 0.5,
+ 10,
+ 0.1
+ )
+ ).toEqual([{ value: 1.3, focalLength: '50MM' }]);
+ });
+
+ test('matches focal-length metadata within half a display step', () => {
+ const stops = [
+ { value: 0.5, focalLength: '13MM' },
+ { value: 1, focalLength: '26MM' },
+ ];
+ expect(findCameraZoomStop(1.04, stops, 0.1)?.focalLength).toBe('26MM');
+ expect(findCameraZoomStop(1.06, stops, 0.1)).toBeUndefined();
+ });
+
+ test('keeps the logarithmically nearest optical stop active between stops', () => {
+ const stops = [{ value: 0.5 }, { value: 1 }, { value: 2 }];
+ expect(findNearestCameraZoomStop(0.7, stops)?.value).toBe(0.5);
+ expect(findNearestCameraZoomStop(1.5, stops)?.value).toBe(2);
+ expect(findNearestCameraZoomStop(7.4, stops)?.value).toBe(2);
+ expect(findNearestCameraZoomStop(1, [])).toBeUndefined();
+ });
+});
diff --git a/packages/dial-slider/tests/public-api.test.ts b/packages/dial-slider/tests/public-api.test.ts
index cd8e848..4784ab8 100644
--- a/packages/dial-slider/tests/public-api.test.ts
+++ b/packages/dial-slider/tests/public-api.test.ts
@@ -6,6 +6,9 @@ const packageRoot = join(import.meta.dir, '..');
describe('public package surface', () => {
test('exports the component and supported public types only', async () => {
const source = await Bun.file(join(packageRoot, 'src/index.ts')).text();
+ expect(source).toContain('export { CameraZoomDial }');
+ expect(source).toContain('CameraZoomDialProps');
+ expect(source).toContain('CameraZoomStop');
expect(source).toContain('export { DialSlider }');
expect(source).toContain('DialPreset');
expect(source).toContain('DialSliderProps');
diff --git a/packages/dial-slider/tests/public-api.types.tsx b/packages/dial-slider/tests/public-api.types.tsx
index 6946db8..dcad018 100644
--- a/packages/dial-slider/tests/public-api.types.tsx
+++ b/packages/dial-slider/tests/public-api.types.tsx
@@ -1,5 +1,7 @@
-import { DialSlider } from '@ngocdevv/dial-slider';
+import { CameraZoomDial, DialSlider } from '@ngocdevv/dial-slider';
import type {
+ CameraZoomDialProps,
+ CameraZoomStop,
DialPreset,
DialSliderProps,
DialSliderValues,
@@ -16,3 +18,17 @@ const props: DialSliderProps = {
};
export const publicApiTypeFixture = ;
+
+const zoomStops: readonly CameraZoomStop[] = [
+ { value: 0.5, compactLabel: '.5', focalLength: '13MM' },
+ { value: 1, focalLength: '26MM' },
+ { value: 2 },
+];
+const zoomProps: CameraZoomDialProps = {
+ minZoom: 0.5,
+ maxZoom: 10,
+ zoomStops,
+ onZoomChange: (_zoom) => {},
+};
+
+export const cameraZoomPublicApiTypeFixture = ;