// 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 CurvedArcBar = React.memo(function CurvedArcBar({
cx,
cy,
rInner,
rOuter,
startDeg,
endDeg,
percent,
backgroundColor,
fillColor,
fillFromEnd = false,
}) {
const safePercent = clamp01(percent ?? 0);
const rMid = (rInner + rOuter) / 2;
const strokeWidth = rOuter - rInner;
const backgroundPath = useMemo(
() => describeArc(cx, cy, rMid, startDeg, endDeg),
[cx, cy, endDeg, rMid, startDeg],
);
const fillPath = useMemo(() => {
// The foreground bar uses the same arc geometry as the background pill.
// Mirroring is done by anchoring the fill to the opposite end of the arc,
// which keeps left/right cliff sensors visually symmetric around the robot.
const span = endDeg - startDeg;
const fillSpan = span * safePercent;
const fillStart = fillFromEnd ? endDeg - fillSpan : startDeg;
const fillEnd = fillFromEnd ? endDeg : startDeg + fillSpan;
return describeArc(cx, cy, rMid, fillStart, fillEnd);
}, [cx, cy, endDeg, fillFromEnd, rMid, safePercent, startDeg]);
return (
<>
{safePercent > 0 ? (
) : 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, speed, drop, overcurrent, label }) {
const currentMagnitude = Math.abs(current);
const currentPercent = clamp01(currentMagnitude / 1200);
const currentFillColor = currentColor(current, overcurrent);
const hasSpeed = Number.isFinite(Number(speed));
const speedValue = hasSpeed ? Number(speed) : 0;
const speedMagnitude = Math.abs(speedValue);
const speedPercent = clamp01(speedMagnitude / 500);
const barH = 52;
const barW = 8;
const gap = 3;
const groupWidth = 24;
const groupHeight = 58;
const barTop = -barH / 2;
const barBottom = barH / 2;
const outsideSign = label === 'L' ? -1 : 1;
const insideSign = -outsideSign;
/*
The wheel glyphs mirror each other around the robot body. Current belongs
on the inside edge because it is a motor/load signal tied to the chassis,
while speed belongs on the outside edge where wheel motion is easiest to
read at a glance.
*/
const speedCenterX = outsideSign * (barW / 2 + gap / 2);
const currentCenterX = insideSign * (barW / 2 + gap / 2);
const currentFill = barH * currentPercent;
const speedFill = (barH / 2) * speedPercent;
const speedIsForward = speedValue >= 0;
const speedFillY = speedIsForward ? -speedFill : 0;
const speedColor = hasSpeed ? (speedIsForward ? '#38bdf8' : '#f59e0b') : '#475569';
const dropLabelRotation = label === 'L' ? -90 : 90;
return (
{/*
Keep the speed bar in the same compact visual language as the original
wheel current bar. The only extra cue is the zero line: encoder-derived
forward speed fills above it, while reverse speed fills below it.
*/}
{hasSpeed && speedFill > 0 ? (
) : null}
{/*
Current stays as the familiar bottom-up load meter. Keeping both bars
narrow avoids turning this layer into a dashboard and preserves the
original top-down sensor-map density.
*/}
{drop ? (
<>
{/*
The dropped state covers the existing compact wheel visual instead
of adding another status column. This satisfies the "whole wheel is
dropped" meaning without increasing the layer footprint.
*/}
Dropped
>
) : null}
{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 ReadoutBar = React.memo(function ReadoutBar({
x,
y,
width,
height,
label,
valueText,
percent,
color = '#38bdf8',
missing = false,
}) {
const safePercent = clamp01(percent ?? 0);
const fillWidth = width * safePercent;
return (
{label}
{valueText}
);
});
export const RawFrameStrip = React.memo(function RawFrameStrip({ x, y, width, height, bytes }) {
const safeBytes = Array.isArray(bytes) ? bytes : [];
const count = safeBytes.length;
const gap = 0;
const cellWidth = count > 0 ? Math.max(0.8, (width - gap * (count - 1)) / count) : width;
return (
{safeBytes.map((byte, idx) => {
// This strip intentionally visualizes the raw decoded frame bytes
// instead of decoded sensor meanings. Hue makes byte identity visible,
// while bar height makes quiet/low and loud/high byte values distinct.
const value = Number.isFinite(byte) ? Math.max(0, Math.min(255, byte)) : 0;
const normalized = value / 255;
const barHeight = 2 + normalized * (height - 5);
const hue = Math.round(normalized * 300 + 35);
const barX = idx * (cellWidth + gap);
const barY = height - 2 - barHeight;
return (
);
})}
);
});
export const MainBrushVisual = React.memo(function MainBrushVisual({ cx, cy, current, overcurrent, variant, dirtLeft, dirtRight }) {
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;
const renderDirtDot = (dotCx, dotCy, value) => {
// The Create dirt packets are impulse-style 0-255 counters rather than a
// calibrated percentage. A low divisor keeps small real hits visible, while
// clamp01 prevents rare large values from growing beyond the brush layout.
const numericValue = Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0;
const strength = clamp01(numericValue / 80);
const radius = 2.5 + strength * 5.5;
const fill = numericValue > 0 ? '#fbbf24' : '#475569';
const stroke = numericValue > 0 ? '#fde68a' : '#1e293b';
return (
0 ? 0.95 : 0.45}
className={numericValue > 0 ? 'animate-pulse' : ''}
/>
);
};
return (
{[patternA, patternB].map((id, idx) => (
{dur ? (
) : null}
))}
{renderDirtDot(cx - rollerWidth / 4, cy - 8, dirtLeft)}
{renderDirtDot(cx + rollerWidth / 4, cy + 8, dirtRight)}
{overcurrent ? (
) : null}
);
});