feat(vision): integrate vision feed into docked abacus

- Add vision state management to MyAbacusContext (camera, calibration,
  remote session, enabled state)
- Add VisionIndicator component showing vision status on dock header
- Add VisionSetupModal for configuring camera and calibration
- Add DockedVisionFeed component that replaces SVG abacus when vision
  is enabled, with:
  - Continuous ArUco marker detection for auto-calibration
  - OpenCV perspective correction via VisionCameraFeed
  - Real-time bead detection and value display
  - Support for both local camera and remote phone camera
- Wire AbacusVisionBridge to save config to context via
  onConfigurationChange callback
- Update MyAbacus to conditionally render DockedVisionFeed vs
  AbacusReact based on vision state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2026-01-01 15:05:58 -06:00
parent 005140a1e7
commit d8c764595d
6 changed files with 1211 additions and 35 deletions

View File

@@ -9,6 +9,9 @@ import { createRoot } from 'react-dom/client'
import { HomeHeroContext } from '@/contexts/HomeHeroContext'
import { type DockAnimationState, useMyAbacus } from '@/contexts/MyAbacusContext'
import { useTheme } from '@/contexts/ThemeContext'
import { DockedVisionFeed } from '@/components/vision/DockedVisionFeed'
import { VisionIndicator } from '@/components/vision/VisionIndicator'
import { VisionSetupModal } from '@/components/vision/VisionSetupModal'
import { css } from '../../styled-system/css'
/**
@@ -85,6 +88,8 @@ export function MyAbacus() {
clearDockRequest,
abacusValue: contextAbacusValue,
setDockedValue,
visionConfig,
isVisionSetupComplete,
} = useMyAbacus()
const appConfig = useAbacusConfig()
const pathname = usePathname()
@@ -493,6 +498,9 @@ export function MyAbacus() {
position: 'relative',
})}
>
{/* Vision indicator - positioned at top-right, before undock button */}
<VisionIndicator size="small" position="top-left" />
{/* Undock button - positioned at top-right of dock container */}
<button
data-action="undock-abacus"
@@ -536,44 +544,67 @@ export function MyAbacus() {
data-element="abacus-display"
className={css({
filter: 'drop-shadow(0 4px 12px rgba(251, 191, 36, 0.2))',
width: '100%',
height: '100%',
})}
>
<AbacusReact
key="docked"
value={dock.value ?? abacusValue}
defaultValue={dock.defaultValue}
columns={dock.columns ?? 5}
scaleFactor={effectiveScaleFactor}
beadShape={appConfig.beadShape}
showNumbers={dock.showNumbers ?? true}
interactive={dock.interactive ?? true}
animated={dock.animated ?? true}
customStyles={structuralStyles}
onValueChange={(newValue: number | bigint) => {
const numValue = Number(newValue)
// Update the appropriate state based on dock mode
// (unless dock provides its own value prop for full control)
if (dock.value === undefined) {
// When docked by user, update context value; otherwise update local/hero
if (isDockedByUser) {
setDockedValue(numValue)
} else {
setAbacusValue(numValue)
{/* Show vision feed when enabled, otherwise show digital abacus */}
{visionConfig.enabled && isVisionSetupComplete ? (
<DockedVisionFeed
columnCount={dock.columns ?? 5}
onValueDetected={(value) => {
// Update the appropriate state based on dock mode
if (dock.value === undefined) {
if (isDockedByUser) {
setDockedValue(value)
} else {
setAbacusValue(value)
}
}
}
// Also call dock's callback if provided
if (dock.onValueChange) {
dock.onValueChange(numValue)
}
}}
enhanced3d="realistic"
material3d={{
heavenBeads: 'glossy',
earthBeads: 'satin',
lighting: 'dramatic',
woodGrain: true,
}}
/>
// Also call dock's callback if provided
if (dock.onValueChange) {
dock.onValueChange(value)
}
}}
/>
) : (
<AbacusReact
key="docked"
value={dock.value ?? abacusValue}
defaultValue={dock.defaultValue}
columns={dock.columns ?? 5}
scaleFactor={effectiveScaleFactor}
beadShape={appConfig.beadShape}
showNumbers={dock.showNumbers ?? true}
interactive={dock.interactive ?? true}
animated={dock.animated ?? true}
customStyles={structuralStyles}
onValueChange={(newValue: number | bigint) => {
const numValue = Number(newValue)
// Update the appropriate state based on dock mode
// (unless dock provides its own value prop for full control)
if (dock.value === undefined) {
// When docked by user, update context value; otherwise update local/hero
if (isDockedByUser) {
setDockedValue(numValue)
} else {
setAbacusValue(numValue)
}
}
// Also call dock's callback if provided
if (dock.onValueChange) {
dock.onValueChange(numValue)
}
}}
enhanced3d="realistic"
material3d={{
heavenBeads: 'glossy',
earthBeads: 'satin',
lighting: 'dramatic',
woodGrain: true,
}}
/>
)}
</div>
</div>,
dock.element
@@ -820,6 +851,9 @@ export function MyAbacus() {
`,
}}
/>
{/* Vision setup modal - controlled by context state */}
<VisionSetupModal />
</>
)
}

View File

@@ -19,6 +19,18 @@ import { VisionStatusIndicator } from './VisionStatusIndicator'
type CameraSource = 'local' | 'phone'
/**
* Configuration change payload for onConfigurationChange callback
*/
export interface VisionConfigurationChange {
/** Camera device ID (for local camera) */
cameraDeviceId?: string | null
/** Calibration grid */
calibration?: import('@/types/vision').CalibrationGrid | null
/** Remote camera session ID (for phone camera) */
remoteCameraSessionId?: string | null
}
export interface AbacusVisionBridgeProps {
/** Number of abacus columns to detect */
columnCount: number
@@ -28,6 +40,8 @@ export interface AbacusVisionBridgeProps {
onClose: () => void
/** Called on error */
onError?: (error: string) => void
/** Called when configuration changes (camera, calibration, or remote session) */
onConfigurationChange?: (config: VisionConfigurationChange) => void
}
/**
@@ -44,6 +58,7 @@ export function AbacusVisionBridge({
onValueDetected,
onClose,
onError,
onConfigurationChange,
}: AbacusVisionBridgeProps): ReactNode {
const [videoDimensions, setVideoDimensions] = useState<{
width: number
@@ -110,6 +125,11 @@ export function AbacusVisionBridge({
const lastRemoteInferenceTimeRef = useRef<number>(0)
const REMOTE_INFERENCE_INTERVAL_MS = 100 // 10fps
// Track last reported configuration to avoid redundant callbacks
const lastReportedCameraRef = useRef<string | null>(null)
const lastReportedCalibrationRef = useRef<CalibrationGrid | null>(null)
const lastReportedRemoteSessionRef = useRef<string | null>(null)
// Handle switching to phone camera
const handleCameraSourceChange = useCallback(
(source: CameraSource) => {
@@ -208,6 +228,54 @@ export function AbacusVisionBridge({
}
}, [vision.cameraError, onError])
// Notify about local camera device changes
useEffect(() => {
if (
cameraSource === 'local' &&
vision.selectedDeviceId &&
vision.selectedDeviceId !== lastReportedCameraRef.current
) {
lastReportedCameraRef.current = vision.selectedDeviceId
onConfigurationChange?.({ cameraDeviceId: vision.selectedDeviceId })
}
}, [cameraSource, vision.selectedDeviceId, onConfigurationChange])
// Notify about local calibration changes
useEffect(() => {
if (
cameraSource === 'local' &&
vision.calibrationGrid &&
vision.calibrationGrid !== lastReportedCalibrationRef.current
) {
lastReportedCalibrationRef.current = vision.calibrationGrid
onConfigurationChange?.({ calibration: vision.calibrationGrid })
}
}, [cameraSource, vision.calibrationGrid, onConfigurationChange])
// Notify about remote camera session changes
useEffect(() => {
if (
cameraSource === 'phone' &&
remoteCameraSessionId &&
remoteCameraSessionId !== lastReportedRemoteSessionRef.current
) {
lastReportedRemoteSessionRef.current = remoteCameraSessionId
onConfigurationChange?.({ remoteCameraSessionId })
}
}, [cameraSource, remoteCameraSessionId, onConfigurationChange])
// Notify about remote calibration changes (manual mode)
useEffect(() => {
if (
cameraSource === 'phone' &&
remoteCalibration &&
remoteCalibration !== lastReportedCalibrationRef.current
) {
lastReportedCalibrationRef.current = remoteCalibration
onConfigurationChange?.({ calibration: remoteCalibration })
}
}, [cameraSource, remoteCalibration, onConfigurationChange])
// Process remote camera frames through CV pipeline
useEffect(() => {
// Only process when using phone camera and connected

View File

@@ -0,0 +1,555 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { useRemoteCameraDesktop } from '@/hooks/useRemoteCameraDesktop'
import { analyzeColumns, analysesToDigits, digitsToNumber } from '@/lib/vision/beadDetector'
import { processVideoFrame, processImageFrame } from '@/lib/vision/frameProcessor'
import {
cleanupArucoDetector,
detectMarkers,
initArucoDetector,
isArucoAvailable,
loadAruco,
} from '@/lib/vision/arucoDetection'
import { useFrameStability } from '@/hooks/useFrameStability'
import { VisionCameraFeed } from './VisionCameraFeed'
import { css } from '../../../styled-system/css'
import type { CalibrationGrid } from '@/types/vision'
interface DockedVisionFeedProps {
/** Called when a stable value is detected */
onValueDetected?: (value: number) => void
/** Number of columns to detect */
columnCount?: number
}
/**
* Renders the processed camera feed in place of the docked abacus
*
* When vision is enabled in MyAbacusContext, this component:
* - For local camera: Opens the saved camera, applies calibration, runs detection
* - For remote camera: Receives frames from phone, runs detection
* - Shows the video feed with detection overlay
*/
export function DockedVisionFeed({ onValueDetected, columnCount = 5 }: DockedVisionFeedProps) {
const { visionConfig, setDockedValue, setVisionEnabled, setVisionCalibration } = useMyAbacus()
const videoRef = useRef<HTMLVideoElement>(null)
const remoteImageRef = useRef<HTMLImageElement>(null)
const animationFrameRef = useRef<number | null>(null)
const markerDetectionFrameRef = useRef<number | null>(null)
const lastInferenceTimeRef = useRef<number>(0)
const [videoStream, setVideoStream] = useState<MediaStream | null>(null)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [detectedValue, setDetectedValue] = useState<number | null>(null)
const [confidence, setConfidence] = useState(0)
const [isArucoReady, setIsArucoReady] = useState(false)
const [markersFound, setMarkersFound] = useState(0)
const stability = useFrameStability()
// Determine camera source (must be before effects that use these)
// Prioritize local camera if configured - remote camera only if no local camera
const isLocalCamera = visionConfig.cameraDeviceId !== null
const isRemoteCamera = !isLocalCamera && visionConfig.remoteCameraSessionId !== null
// Load and initialize ArUco on mount (for local camera auto-calibration)
useEffect(() => {
if (!isLocalCamera) return
let cancelled = false
const initAruco = async () => {
try {
await loadAruco()
if (cancelled) return
const available = isArucoAvailable()
if (available) {
initArucoDetector()
setIsArucoReady(true)
}
} catch (err) {
console.error('[DockedVisionFeed] Failed to load ArUco:', err)
}
}
initAruco()
return () => {
cancelled = true
}
}, [isLocalCamera])
// Cleanup ArUco detector on unmount
useEffect(() => {
return () => {
cleanupArucoDetector()
}
}, [])
// Auto-calibration loop using ArUco markers (for local camera)
useEffect(() => {
if (!visionConfig.enabled || !isLocalCamera || !videoStream || !isArucoReady) {
if (markerDetectionFrameRef.current) {
cancelAnimationFrame(markerDetectionFrameRef.current)
markerDetectionFrameRef.current = null
}
return
}
const video = videoRef.current
if (!video) return
let running = true
const detectLoop = () => {
if (!running || !video || video.readyState < 2) {
if (running) {
markerDetectionFrameRef.current = requestAnimationFrame(detectLoop)
}
return
}
const result = detectMarkers(video)
setMarkersFound(result.markersFound)
// Auto-update calibration when all 4 markers found
if (result.allMarkersFound && result.quadCorners) {
const grid: CalibrationGrid = {
roi: {
x: Math.min(result.quadCorners.topLeft.x, result.quadCorners.bottomLeft.x),
y: Math.min(result.quadCorners.topLeft.y, result.quadCorners.topRight.y),
width:
Math.max(result.quadCorners.topRight.x, result.quadCorners.bottomRight.x) -
Math.min(result.quadCorners.topLeft.x, result.quadCorners.bottomLeft.x),
height:
Math.max(result.quadCorners.bottomLeft.y, result.quadCorners.bottomRight.y) -
Math.min(result.quadCorners.topLeft.y, result.quadCorners.topRight.y),
},
corners: result.quadCorners,
columnCount,
columnDividers: Array.from({ length: columnCount - 1 }, (_, i) => (i + 1) / columnCount),
rotation: 0,
}
// Update calibration in context
setVisionCalibration(grid)
}
markerDetectionFrameRef.current = requestAnimationFrame(detectLoop)
}
detectLoop()
return () => {
running = false
if (markerDetectionFrameRef.current) {
cancelAnimationFrame(markerDetectionFrameRef.current)
markerDetectionFrameRef.current = null
}
}
}, [visionConfig.enabled, isLocalCamera, videoStream, isArucoReady, columnCount, setVisionCalibration])
// Remote camera hook
const {
isPhoneConnected: remoteIsPhoneConnected,
latestFrame: remoteLatestFrame,
subscribe: remoteSubscribe,
unsubscribe: remoteUnsubscribe,
} = useRemoteCameraDesktop()
const INFERENCE_INTERVAL_MS = 100 // 10fps
// Start local camera when component mounts (only for local camera)
useEffect(() => {
if (!visionConfig.enabled || !isLocalCamera || !visionConfig.cameraDeviceId) {
return
}
let cancelled = false
setIsLoading(true)
setError(null)
const startCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: visionConfig.cameraDeviceId! },
width: { ideal: 1280 },
height: { ideal: 720 },
},
})
if (cancelled) {
stream.getTracks().forEach((track) => track.stop())
return
}
setVideoStream(stream)
setIsLoading(false)
} catch (err) {
if (cancelled) return
console.error('[DockedVisionFeed] Failed to start camera:', err)
setError('Failed to access camera')
setIsLoading(false)
}
}
startCamera()
return () => {
cancelled = true
}
}, [visionConfig.enabled, isLocalCamera, visionConfig.cameraDeviceId])
// Stop camera when stream changes or component unmounts
useEffect(() => {
return () => {
if (videoStream) {
videoStream.getTracks().forEach((track) => track.stop())
}
}
}, [videoStream])
// Attach stream to video element
useEffect(() => {
if (videoRef.current && videoStream) {
videoRef.current.srcObject = videoStream
}
}, [videoStream])
// Subscribe to remote camera session
useEffect(() => {
if (!visionConfig.enabled || !isRemoteCamera || !visionConfig.remoteCameraSessionId) {
return
}
setIsLoading(true)
remoteSubscribe(visionConfig.remoteCameraSessionId)
return () => {
remoteUnsubscribe()
}
}, [visionConfig.enabled, isRemoteCamera, visionConfig.remoteCameraSessionId, remoteSubscribe, remoteUnsubscribe])
// Update loading state when remote camera connects
useEffect(() => {
if (isRemoteCamera && remoteIsPhoneConnected) {
setIsLoading(false)
}
}, [isRemoteCamera, remoteIsPhoneConnected])
// Process local camera frames for detection
const processLocalFrame = useCallback(() => {
const now = performance.now()
if (now - lastInferenceTimeRef.current < INFERENCE_INTERVAL_MS) {
return
}
lastInferenceTimeRef.current = now
const video = videoRef.current
if (!video || video.readyState < 2) return
if (!visionConfig.calibration) return
// Process video frame into column strips
const columnImages = processVideoFrame(video, visionConfig.calibration)
if (columnImages.length === 0) return
// Use CV-based bead detection
const analyses = analyzeColumns(columnImages)
const { digits, minConfidence } = analysesToDigits(analyses)
// Convert to number
const value = digitsToNumber(digits)
// Push to stability buffer
stability.pushFrame(value, minConfidence)
}, [visionConfig.calibration, stability])
// Process remote camera frames for detection
useEffect(() => {
if (!isRemoteCamera || !remoteIsPhoneConnected || !remoteLatestFrame) {
return
}
const now = performance.now()
if (now - lastInferenceTimeRef.current < INFERENCE_INTERVAL_MS) {
return
}
lastInferenceTimeRef.current = now
const image = remoteImageRef.current
if (!image || !image.complete || image.naturalWidth === 0) {
return
}
// Phone sends pre-cropped frames in auto mode, so no calibration needed
const columnImages = processImageFrame(image, null, columnCount)
if (columnImages.length === 0) return
// Use CV-based bead detection
const analyses = analyzeColumns(columnImages)
const { digits, minConfidence } = analysesToDigits(analyses)
// Convert to number
const value = digitsToNumber(digits)
// Push to stability buffer
stability.pushFrame(value, minConfidence)
}, [isRemoteCamera, remoteIsPhoneConnected, remoteLatestFrame, columnCount, stability])
// Local camera detection loop
useEffect(() => {
if (!visionConfig.enabled || !isLocalCamera || !videoStream || !visionConfig.calibration) {
return
}
let running = true
const loop = () => {
if (!running) return
processLocalFrame()
animationFrameRef.current = requestAnimationFrame(loop)
}
loop()
return () => {
running = false
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
}
}, [visionConfig.enabled, isLocalCamera, videoStream, visionConfig.calibration, processLocalFrame])
// Handle stable value changes
useEffect(() => {
if (stability.stableValue !== null && stability.stableValue !== detectedValue) {
setDetectedValue(stability.stableValue)
setConfidence(stability.currentConfidence)
setDockedValue(stability.stableValue)
onValueDetected?.(stability.stableValue)
}
}, [stability.stableValue, stability.currentConfidence, detectedValue, setDockedValue, onValueDetected])
const handleDisableVision = (e: React.MouseEvent) => {
e.stopPropagation()
setVisionEnabled(false)
if (videoStream) {
videoStream.getTracks().forEach((track) => track.stop())
}
}
if (error) {
return (
<div
data-component="docked-vision-feed"
data-status="error"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
p: 4,
bg: 'red.900/30',
borderRadius: 'lg',
color: 'red.400',
textAlign: 'center',
})}
>
<span className={css({ fontSize: 'xl' })}></span>
<span className={css({ fontSize: 'sm' })}>{error}</span>
<button
type="button"
onClick={handleDisableVision}
className={css({
mt: 2,
px: 3,
py: 1,
bg: 'gray.700',
color: 'white',
borderRadius: 'md',
fontSize: 'xs',
border: 'none',
cursor: 'pointer',
})}
>
Disable Vision
</button>
</div>
)
}
if (isLoading) {
return (
<div
data-component="docked-vision-feed"
data-status="loading"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
p: 4,
bg: 'gray.800/50',
borderRadius: 'lg',
color: 'gray.400',
})}
>
<span className={css({ fontSize: 'xl' })}>📷</span>
<span className={css({ fontSize: 'sm' })}>
{isRemoteCamera ? 'Connecting to phone...' : 'Starting camera...'}
</span>
</div>
)
}
return (
<div
data-component="docked-vision-feed"
data-status="active"
data-source={isRemoteCamera ? 'remote' : 'local'}
className={css({
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
overflow: 'hidden',
borderRadius: 'lg',
bg: 'black',
width: '100%',
height: '100%',
})}
>
{/* Rectified video feed - local camera */}
{isLocalCamera && (
<VisionCameraFeed
videoStream={videoStream}
calibration={visionConfig.calibration}
showRectifiedView={true}
videoRef={(el) => {
videoRef.current = el
}}
/>
)}
{/* Remote camera feed */}
{isRemoteCamera && remoteLatestFrame && (
<img
ref={remoteImageRef}
src={`data:image/jpeg;base64,${remoteLatestFrame.imageData}`}
alt="Phone camera view"
className={css({
width: '100%',
height: 'auto',
objectFit: 'contain',
})}
/>
)}
{/* Waiting for remote frames */}
{isRemoteCamera && !remoteLatestFrame && remoteIsPhoneConnected && (
<div
className={css({
width: '100%',
aspectRatio: '2/1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'gray.400',
fontSize: 'sm',
})}
>
Waiting for frames...
</div>
)}
{/* Detection overlay */}
<div
data-element="detection-overlay"
className={css({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
bg: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(4px)',
})}
>
{/* Detected value */}
<div className={css({ display: 'flex', alignItems: 'center', gap: 2 })}>
<span className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'white', fontFamily: 'mono' })}>
{detectedValue !== null ? detectedValue : '---'}
</span>
{detectedValue !== null && (
<span className={css({ fontSize: 'xs', color: 'gray.400' })}>
{Math.round(confidence * 100)}%
</span>
)}
</div>
{/* Stability indicator */}
<div className={css({ display: 'flex', alignItems: 'center', gap: 1 })}>
{stability.consecutiveFrames > 0 && (
<div className={css({ display: 'flex', gap: 0.5 })}>
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className={css({
w: '6px',
h: '6px',
borderRadius: 'full',
bg: i < stability.consecutiveFrames ? 'green.500' : 'gray.600',
})}
/>
))}
</div>
)}
</div>
</div>
{/* Disable button */}
<button
type="button"
data-action="disable-vision"
onClick={handleDisableVision}
title="Disable vision mode"
className={css({
position: 'absolute',
top: '4px',
right: '4px',
w: '24px',
h: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: 'md',
color: 'white',
fontSize: 'xs',
cursor: 'pointer',
zIndex: 10,
opacity: 0.7,
_hover: {
bg: 'rgba(239, 68, 68, 0.8)',
opacity: 1,
},
})}
>
</button>
</div>
)
}

View File

@@ -0,0 +1,123 @@
'use client'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { css } from '../../../styled-system/css'
interface VisionIndicatorProps {
/** Size variant */
size?: 'small' | 'medium'
/** Position for absolute placement */
position?: 'top-left' | 'bottom-right'
}
/**
* Camera icon indicator for abacus vision mode
*
* Shows:
* - 🔴 Red dot = not configured (no camera or no calibration)
* - 🟢 Green dot = configured and enabled
* - ⚪ Gray = configured but disabled
*
* Click behavior:
* - If not configured: opens setup modal
* - If configured: toggles vision on/off
*/
export function VisionIndicator({ size = 'medium', position = 'bottom-right' }: VisionIndicatorProps) {
const { visionConfig, isVisionSetupComplete, openVisionSetup } = useMyAbacus()
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
console.log('[VisionIndicator] Click detected, isVisionSetupComplete:', isVisionSetupComplete)
// Always open setup modal on click for now
// This gives users easy access to vision settings
// In the future, we could add quick-toggle behavior
console.log('[VisionIndicator] Opening setup modal...')
openVisionSetup()
}
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
// Right-click always opens setup
openVisionSetup()
}
// Determine status indicator color
const statusColor = !isVisionSetupComplete
? 'red.500' // Not configured
: visionConfig.enabled
? 'green.500' // Enabled
: 'gray.400' // Configured but disabled
const statusLabel = !isVisionSetupComplete
? 'Vision not configured'
: visionConfig.enabled
? 'Vision enabled'
: 'Vision disabled'
const sizeStyles =
size === 'small'
? { w: '20px', h: '20px', fontSize: '10px' }
: { w: '28px', h: '28px', fontSize: '14px' }
const positionStyles =
position === 'top-left'
? { top: 0, left: 0, margin: '4px' }
: { bottom: 0, right: 0, margin: '4px' }
return (
<button
type="button"
data-action="toggle-vision"
data-vision-status={!isVisionSetupComplete ? 'not-configured' : visionConfig.enabled ? 'enabled' : 'disabled'}
onClick={handleClick}
onContextMenu={handleContextMenu}
title={`${statusLabel} (right-click for settings)`}
style={{
position: 'absolute',
...positionStyles,
}}
className={css({
...sizeStyles,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: 'md',
color: 'white',
cursor: 'pointer',
transition: 'all 0.2s',
zIndex: 10,
opacity: 0.8,
_hover: {
bg: 'rgba(0, 0, 0, 0.7)',
opacity: 1,
transform: 'scale(1.1)',
},
})}
>
{/* Camera icon */}
<span style={{ position: 'relative' }}>
📷
{/* Status dot */}
<span
data-element="vision-status-dot"
className={css({
position: 'absolute',
top: '-2px',
right: '-4px',
w: '8px',
h: '8px',
borderRadius: 'full',
bg: statusColor,
border: '1px solid white',
boxShadow: '0 1px 2px rgba(0,0,0,0.3)',
})}
/>
</span>
</button>
)
}

View File

@@ -0,0 +1,253 @@
'use client'
import { useState } from 'react'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { Modal, ModalContent } from '@/components/common/Modal'
import { AbacusVisionBridge } from './AbacusVisionBridge'
import { css } from '../../../styled-system/css'
/**
* Modal for configuring abacus vision settings
*
* Shows status and launches AbacusVisionBridge for configuration.
* AbacusVisionBridge saves camera/calibration to MyAbacusContext.
*/
export function VisionSetupModal() {
const {
isVisionSetupOpen,
closeVisionSetup,
visionConfig,
isVisionSetupComplete,
setVisionEnabled,
setVisionCamera,
setVisionCalibration,
setVisionRemoteSession,
dock,
} = useMyAbacus()
// State for showing the configuration UI
const [isConfiguring, setIsConfiguring] = useState(false)
const handleClearSettings = () => {
setVisionCamera(null)
setVisionCalibration(null)
setVisionRemoteSession(null)
setVisionEnabled(false)
}
return (
<Modal isOpen={isVisionSetupOpen} onClose={closeVisionSetup}>
<ModalContent
title="📷 Abacus Vision"
description="Use a camera to detect your physical abacus"
borderColor="rgba(34, 211, 238, 0.3)"
titleColor="rgba(34, 211, 238, 1)"
>
<div className={css({ display: 'flex', flexDirection: 'column', gap: 4 })}>
{/* Status display */}
<div
data-element="vision-status"
className={css({
bg: 'rgba(0, 0, 0, 0.3)',
borderRadius: 'lg',
p: 4,
})}
>
<h3 className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'gray.300', mb: 2 })}>
Status
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: 2 })}>
<StatusRow
label="Camera"
value={visionConfig.cameraDeviceId ? 'Configured' : 'Not configured'}
isConfigured={visionConfig.cameraDeviceId !== null}
/>
<StatusRow
label="Calibration"
value={visionConfig.calibration ? 'Configured' : 'Not configured'}
isConfigured={visionConfig.calibration !== null}
/>
<StatusRow
label="Remote Phone"
value={visionConfig.remoteCameraSessionId ? 'Connected' : 'Not connected'}
isConfigured={visionConfig.remoteCameraSessionId !== null}
/>
<StatusRow
label="Vision Mode"
value={visionConfig.enabled ? 'Enabled' : 'Disabled'}
isConfigured={visionConfig.enabled}
/>
</div>
</div>
{/* Actions */}
<div className={css({ display: 'flex', flexDirection: 'column', gap: 2 })}>
{isVisionSetupComplete && (
<button
type="button"
data-action="toggle-vision"
onClick={() => {
setVisionEnabled(!visionConfig.enabled)
}}
className={css({
px: 4,
py: 3,
bg: visionConfig.enabled ? 'red.600' : 'green.600',
color: 'white',
borderRadius: 'lg',
fontWeight: 'semibold',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: visionConfig.enabled ? 'red.700' : 'green.700',
transform: 'scale(1.02)',
},
})}
>
{visionConfig.enabled ? 'Disable Vision' : 'Enable Vision'}
</button>
)}
<button
type="button"
data-action="configure-camera"
onClick={() => setIsConfiguring(true)}
className={css({
px: 4,
py: 3,
bg: 'cyan.600',
color: 'white',
borderRadius: 'lg',
fontWeight: 'semibold',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: 'cyan.700',
transform: 'scale(1.02)',
},
})}
>
{isVisionSetupComplete ? 'Reconfigure Camera' : 'Configure Camera & Calibration'}
</button>
{isVisionSetupComplete && (
<button
type="button"
data-action="clear-settings"
onClick={handleClearSettings}
className={css({
px: 4,
py: 2,
bg: 'transparent',
color: 'gray.400',
borderRadius: 'lg',
fontWeight: 'medium',
border: '1px solid',
borderColor: 'gray.600',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'gray.500',
color: 'gray.300',
},
})}
>
Clear All Settings
</button>
)}
</div>
{/* Close button */}
<button
type="button"
data-action="close-modal"
onClick={closeVisionSetup}
className={css({
mt: 2,
px: 4,
py: 2,
bg: 'gray.700',
color: 'white',
borderRadius: 'lg',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: 'gray.600',
},
})}
>
Close
</button>
</div>
</ModalContent>
{/* AbacusVisionBridge overlay for configuration */}
{isConfiguring && (
<div
data-element="vision-config-overlay"
className={css({
position: 'fixed',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'rgba(0, 0, 0, 0.8)',
zIndex: 1000,
})}
>
<AbacusVisionBridge
columnCount={dock?.columns ?? 5}
onValueDetected={(value) => {
// Value detected - configuration is working
console.log('[VisionSetupModal] Value detected:', value)
}}
onClose={() => setIsConfiguring(false)}
onConfigurationChange={(config) => {
// Save configuration to context as it changes
if (config.cameraDeviceId !== undefined) {
setVisionCamera(config.cameraDeviceId)
}
if (config.calibration !== undefined) {
setVisionCalibration(config.calibration)
}
if (config.remoteCameraSessionId !== undefined) {
setVisionRemoteSession(config.remoteCameraSessionId)
}
}}
/>
</div>
)}
</Modal>
)
}
/**
* Status row component
*/
function StatusRow({
label,
value,
isConfigured,
}: {
label: string
value: string
isConfigured: boolean
}) {
return (
<div className={css({ display: 'flex', justifyContent: 'space-between', alignItems: 'center' })}>
<span className={css({ color: 'gray.400', fontSize: 'sm' })}>{label}</span>
<span
className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: isConfigured ? 'green.400' : 'gray.500',
})}
>
{value}
</span>
</div>
)
}

View File

@@ -6,9 +6,68 @@ import {
type MutableRefObject,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import type { CalibrationGrid } from '@/types/vision'
/**
* Configuration for abacus vision (camera-based input)
*/
export interface VisionConfig {
/** Whether vision mode is enabled */
enabled: boolean
/** Selected camera device ID */
cameraDeviceId: string | null
/** Saved calibration grid for cropping */
calibration: CalibrationGrid | null
/** Remote phone camera session ID (for phone-as-camera mode) */
remoteCameraSessionId: string | null
}
const DEFAULT_VISION_CONFIG: VisionConfig = {
enabled: false,
cameraDeviceId: null,
calibration: null,
remoteCameraSessionId: null,
}
const VISION_CONFIG_STORAGE_KEY = 'abacus-vision-config'
/**
* Load vision config from localStorage
*/
function loadVisionConfig(): VisionConfig {
if (typeof window === 'undefined') return DEFAULT_VISION_CONFIG
try {
const stored = localStorage.getItem(VISION_CONFIG_STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored)
return {
...DEFAULT_VISION_CONFIG,
...parsed,
// Always start with vision disabled - user must re-enable
enabled: false,
}
}
} catch (e) {
console.error('[MyAbacusContext] Failed to load vision config:', e)
}
return DEFAULT_VISION_CONFIG
}
/**
* Save vision config to localStorage
*/
function saveVisionConfig(config: VisionConfig): void {
if (typeof window === 'undefined') return
try {
localStorage.setItem(VISION_CONFIG_STORAGE_KEY, JSON.stringify(config))
} catch (e) {
console.error('[MyAbacusContext] Failed to save vision config:', e)
}
}
/**
* Configuration for a docked abacus
@@ -107,6 +166,25 @@ interface MyAbacusContextValue {
setDockedValue: (value: number) => void
/** Current abacus value (for reading) */
abacusValue: number
// Vision-related state
/** Current vision configuration */
visionConfig: VisionConfig
/** Whether vision setup is complete (has camera and calibration) */
isVisionSetupComplete: boolean
/** Set whether vision is enabled */
setVisionEnabled: (enabled: boolean) => void
/** Set the selected camera device ID */
setVisionCamera: (deviceId: string | null) => void
/** Set the calibration grid */
setVisionCalibration: (calibration: CalibrationGrid | null) => void
/** Set the remote camera session ID */
setVisionRemoteSession: (sessionId: string | null) => void
/** Whether the vision setup modal is open */
isVisionSetupOpen: boolean
/** Open the vision setup modal */
openVisionSetup: () => void
/** Close the vision setup modal */
closeVisionSetup: () => void
}
const MyAbacusContext = createContext<MyAbacusContextValue | undefined>(undefined)
@@ -124,6 +202,16 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
const [pendingDockRequest, setPendingDockRequest] = useState(false)
const [abacusValue, setAbacusValue] = useState(0)
// Vision state
const [visionConfig, setVisionConfig] = useState<VisionConfig>(DEFAULT_VISION_CONFIG)
const [isVisionSetupOpen, setIsVisionSetupOpen] = useState(false)
// Load vision config from localStorage on mount
useEffect(() => {
const loaded = loadVisionConfig()
setVisionConfig(loaded)
}, [])
const open = useCallback(() => setIsOpen(true), [])
const close = useCallback(() => setIsOpen(false), [])
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
@@ -200,6 +288,51 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
setAbacusValue(value)
}, [])
// Vision callbacks
const isVisionSetupComplete =
visionConfig.cameraDeviceId !== null && visionConfig.calibration !== null
const setVisionEnabled = useCallback((enabled: boolean) => {
setVisionConfig((prev) => {
const updated = { ...prev, enabled }
saveVisionConfig(updated)
return updated
})
}, [])
const setVisionCamera = useCallback((deviceId: string | null) => {
setVisionConfig((prev) => {
const updated = { ...prev, cameraDeviceId: deviceId }
saveVisionConfig(updated)
return updated
})
}, [])
const setVisionCalibration = useCallback((calibration: CalibrationGrid | null) => {
setVisionConfig((prev) => {
const updated = { ...prev, calibration }
saveVisionConfig(updated)
return updated
})
}, [])
const setVisionRemoteSession = useCallback((sessionId: string | null) => {
setVisionConfig((prev) => {
const updated = { ...prev, remoteCameraSessionId: sessionId }
saveVisionConfig(updated)
return updated
})
}, [])
const openVisionSetup = useCallback(() => {
console.log('[MyAbacusContext] openVisionSetup called')
setIsVisionSetupOpen(true)
}, [])
const closeVisionSetup = useCallback(() => {
console.log('[MyAbacusContext] closeVisionSetup called')
setIsVisionSetupOpen(false)
}, [])
return (
<MyAbacusContext.Provider
value={{
@@ -233,6 +366,16 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
clearDockRequest,
setDockedValue,
abacusValue,
// Vision
visionConfig,
isVisionSetupComplete,
setVisionEnabled,
setVisionCamera,
setVisionCalibration,
setVisionRemoteSession,
isVisionSetupOpen,
openVisionSetup,
closeVisionSetup,
}}
>
{children}