// visuals // Purpose: Defines the visuals module and the local helpers/components used in this file. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. import React, { useMemo } from 'react'; import { clamp01, currentColor, describeArc, polarToCartesian, toRad } from './helpers.js'; export const ArcSegment = React.memo(function ArcSegment({ cx, cy, rInner, rOuter, startDeg, endDeg, color, pulse = false, opacity = 1 }) { const rMid = (rInner + rOuter) / 2; const strokeWidth = rOuter - rInner; const path = useMemo( () => describeArc(cx, cy, rMid, startDeg, endDeg), [cx, cy, endDeg, rMid, startDeg], ); return ( <> {pulse ? ( ) : null} ); }); export const ConeSegment = React.memo(function ConeSegment({ cx, cy, rBase, rTip, startDeg, endDeg, color, value, max }) { const mid = (startDeg + endDeg) / 2; const norm = clamp01(value != null ? value / (max || 1) : 0); const eased = Math.pow(norm, 0.35); const filledR = rBase - (rBase - rTip) * eased; const barR = Math.max(rTip, Math.min(filledR, rBase)); const fg = useMemo(() => { // The cone geometry is still dynamic because the filled radius changes // with light-bump strength, but the memo prevents unrelated parent renders // from rebuilding the SVG path string for every cone. const tip = polarToCartesian(cx, cy, rTip, mid); const filledA = polarToCartesian(cx, cy, barR, startDeg); const filledB = polarToCartesian(cx, cy, barR, endDeg); return `M ${tip.x} ${tip.y} L ${filledA.x} ${filledA.y} L ${filledB.x} ${filledB.y} Z`; }, [barR, cx, cy, endDeg, mid, rTip, startDeg]); return ; }); export const WheelVisual = React.memo(function WheelVisual({ cx, cy, current, drop, overcurrent, label }) { const mag = Math.abs(current); const pct = clamp01(mag / 1200); const color = currentColor(current, overcurrent); const barH = 56; const currentW = 14; const dropW = 9; const gap = 3; const currentFill = barH * pct; const sign = label === 'L' ? -1 : 1; const currentCenterX = sign * (-dropW / 2 - gap / 2); const dropCenterX = sign * (currentW / 2 + gap / 2); const groupWidth = currentW + dropW + gap + 2; const groupHeight = barH + 4; return ( {label} ); }); export const SideBrushVisual = React.memo(function SideBrushVisual({ cx, cy, current, overcurrent }) { let mag = Math.abs(current); if (mag < 10) mag = 0; const color = currentColor(current * 3, overcurrent); const armLength = 43; const spinDuration = mag > 0 ? 0.65 : null; const spinDirection = 'reverse'; return ( {[0, 120, 240].map((deg) => { const rad = toRad(deg); const x2 = cx + armLength * Math.cos(rad); const y2 = cy + armLength * Math.sin(rad); return ; })} {overcurrent ? : null} ); }); export const MainBrushVisual = React.memo(function MainBrushVisual({ cx, cy, current, overcurrent, variant }) { const mag = Math.abs(current); const color = currentColor(current, overcurrent); const opacity = 1; const rollerWidth = 96; const rollerHeight = 12; const patternA = `main-brush-pattern-a-${variant}`; const patternB = `main-brush-pattern-b-${variant}`; const dur = mag > 0 ? 0.6 : null; const dir = current >= 0 ? 1 : -1; return ( {[patternA, patternB].map((id, idx) => ( {dur ? ( ) : null} ))} {overcurrent ? ( ) : null} ); });