// 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}
);
});