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 @@ [![CI](https://github.com/ngocdevv/dial-slider/actions/workflows/ci.yml/badge.svg)](https://github.com/ngocdevv/dial-slider/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/%40ngocdevv%2Fdial-slider.svg)](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 ? ( + + + + + + + + + + + + + + {ticks.map((tick) => { + const angle = cameraZoomToAngle( + tick, + 1, + CAMERA_ZOOM_DIAL_GEOMETRY.DEGREES_PER_OCTAVE + ); + const major = isCameraZoomMajorTick( + tick, + zoomStops, + step, + range.min, + range.max + ); + const length = + (major + ? CAMERA_ZOOM_DIAL_GEOMETRY.MAJOR_TICK_LENGTH + : CAMERA_ZOOM_DIAL_GEOMETRY.MINOR_TICK_LENGTH) * unit; + const outer = getPolarPoint( + radius, + outerTickRadius, + angle + ); + const inner = getPolarPoint( + radius, + outerTickRadius - length, + angle + ); + return ( + + ); + })} + + {wheelLabels.map((stop) => { + const relativeAngle = cameraZoomToAngle( + stop.value, + displayZoom, + CAMERA_ZOOM_DIAL_GEOMETRY.DEGREES_PER_OCTAVE + ); + if ( + Math.abs(relativeAngle) < CENTER_LABEL_EXCLUSION_DEGREES + ) { + return null; + } + const angle = cameraZoomToAngle( + stop.value, + 1, + CAMERA_ZOOM_DIAL_GEOMETRY.DEGREES_PER_OCTAVE + ); + const labelPoint = getPolarPoint( + radius, + radius - CAMERA_ZOOM_DIAL_GEOMETRY.LABEL_INSET * unit, + angle + ); + const focalPoint = getPolarPoint( + radius, + radius - + CAMERA_ZOOM_DIAL_GEOMETRY.FOCAL_LABEL_INSET * unit, + angle + ); + const label = + stop.label ?? formatCameraZoomNumber(stop.value, step); + return ( + + + {label} + + {stop.focalLength ? ( + + {stop.focalLength} + + ) : null} + + ); + })} + + + + + + + + + {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 = ;