import { useId, useMemo } from 'preact/hooks';
import './coral-knob.css';
type ActiveColor = 'pink' | 'turquoise' | 'orange' | 'white';
type KnobMode = 'engine' | 'voice' | 'midi' | 'custom';
/** Props for the CoralKnob component */
interface CoralKnobProps {
/** Predefined label set, or "custom" to use the labels prop */
mode?: KnobMode;
/** Active LED positions (1-based). Positions 1-10 map to the 10 LED ring positions */
active?: number[];
/** Color of active LEDs */
activeColor?: ActiveColor;
/** Custom labels for positions 1-10 (only used when mode="custom") */
labels?: string[];
}
/** Props for the CoralKnobFigure wrapper */
interface CoralKnobFigureProps extends CoralKnobProps {
/** Caption text displayed below the diagram */
caption?: string;
/** Remove bottom padding so consecutive figures appear connected */
connected?: boolean;
}
export type { CoralKnobProps, CoralKnobFigureProps, ActiveColor, KnobMode };
// Matches EP.3 article (DRUMS firmware variant; pos 6 is String, pos 7-8 are drum engines)
const ENGINE_LABELS: string[] = [
'2 VCO Virtual Analog',
'Waveshaping',
'2 OP FM',
'Wavetable',
'MDO',
'String',
'Hihat Synth',
'Snare Synth',
'Bassdrum Synth',
'Wav Player',
];
/** Voice mode: 8 voices, positions 9-10 unused */
const VOICE_LABELS: string[] = [
'Voice 1',
'Voice 2',
'Voice 3',
'Voice 4',
'Voice 5',
'Voice 6',
'Voice 7',
'Voice 8',
'',
'',
];
/** MIDI mode: MIDI channels 1-8, positions 9-10 unused */
const MIDI_LABELS: string[] = [
'MIDI Ch 1',
'MIDI Ch 2',
'MIDI Ch 3',
'MIDI Ch 4',
'MIDI Ch 5',
'MIDI Ch 6',
'MIDI Ch 7',
'MIDI Ch 8',
'',
'',
];
/** Stable empty array to avoid useMemo dependency churn when active is not provided */
const EMPTY_ACTIVE: number[] = [];
/** Left side: positions 5,4,3,2,1 displayed top-to-bottom */
const LEFT_POSITIONS = [5, 4, 3, 2, 1] as const;
/** Right side: positions 6,7,8,9,10 displayed top-to-bottom */
const RIGHT_POSITIONS = [6, 7, 8, 9, 10] as const;
/** All 10 positions for LED rendering */
const ALL_POSITIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const;
/* ---------- SVG Layout Constants ---------- */
const SVG_W = 440;
const SVG_H = 260;
const CX = SVG_W / 2;
const CY = SVG_H / 2;
const KNOB_R = 40;
const LED_RING_R = 58;
const LED_DOT_R = 5;
/** LED angles: degrees from 12-o'clock, clockwise */
const LED_ANGLES: Record<number, number> = {
1: 210,
2: 240,
3: 270,
4: 300,
5: 330,
6: 30,
7: 60,
8: 90,
9: 120,
10: 150,
};
/** Active LED fill colors (SVG fill values) */
const COLOR_FILLS: Record<ActiveColor, string> = {
pink: 'rgb(236, 72, 153)',
turquoise: 'rgb(45, 212, 191)',
orange: 'rgb(251, 146, 60)',
white: 'rgb(255, 255, 255)',
};
const INACTIVE_FILL = 'rgb(60, 60, 60)';
const LABEL_FILL = 'rgb(120, 113, 108)';
const LINE_STROKE = 'rgba(120, 113, 108, 0.35)';
/** Label column x-coordinates */
const LABEL_LEFT_X = 148;
const LABEL_RIGHT_X = 292;
/** Vertical span for evenly-spaced label rows */
const LABEL_Y_TOP = 48;
const LABEL_Y_BOTTOM = 212;
const LABEL_Y_STEP = (LABEL_Y_BOTTOM - LABEL_Y_TOP) / 4;
/* ---------- Helpers ---------- */
function getLabelSet(mode: KnobMode, customLabels?: string[]): string[] {
switch (mode) {
case 'engine':
return ENGINE_LABELS;
case 'voice':
return VOICE_LABELS;
case 'midi':
return MIDI_LABELS;
case 'custom':
return customLabels ?? [];
default:
return [];
}
}
/** Convert angle (deg from 12-o'clock CW) to SVG x,y on the LED ring */
function ledXY(angleDeg: number): { x: number; y: number } {
const rad = ((angleDeg - 90) * Math.PI) / 180;
return {
x: CX + LED_RING_R * Math.cos(rad),
y: CY + LED_RING_R * Math.sin(rad),
};
}
/** Y position for the i-th label row (0-based) */
function labelY(index: number): number {
return LABEL_Y_TOP + index * LABEL_Y_STEP;
}
/**
* CoralKnob — visualizes the OXI Coral center encoder knob with 10-position LED ring.
*
* LEDs are positioned in a circle around the knob (matching real hardware).
* Labels appear in readable columns on left/right, with connector lines to their LEDs.
*/
export const CoralKnob = ({
mode = 'engine',
active,
activeColor = 'pink',
labels: customLabels,
}: CoralKnobProps) => {
const gradId = `coral-knob-grad-${useId()}`;
const resolvedActive = active ?? EMPTY_ACTIVE;
const activeSet = useMemo(() => new Set(resolvedActive), [resolvedActive]);
const labelSet = useMemo(() => getLabelSet(mode, customLabels), [mode, customLabels]);
const getLabel = (pos: number): string => labelSet[pos - 1] ?? '';
return (
<div
role="img"
aria-label="OXI Coral encoder knob diagram"
className="w-full rounded-lg bg-[rgb(24,24,24)] p-vgap-sm"
>
<svg
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
className="w-full h-auto block"
xmlns="http://www.w3.org/2000/svg"
>
{/* Knob gradient */}
<defs>
<radialGradient id={gradId}>
<stop offset="0%" stopColor="rgb(120, 50, 100)" />
<stop offset="100%" stopColor="rgb(60, 20, 50)" />
</radialGradient>
</defs>
{/* Connector lines — rendered first so they sit behind dots and labels */}
{LEFT_POSITIONS.map((pos, i) => {
const led = ledXY(LED_ANGLES[pos]);
const ly = labelY(i);
const label = getLabel(pos);
if (!label) return null;
return (
<line
key={`cl-${pos}`}
x1={LABEL_LEFT_X + 6}
y1={ly}
x2={led.x}
y2={led.y}
stroke={LINE_STROKE}
strokeWidth={1}
/>
);
})}
{RIGHT_POSITIONS.map((pos, i) => {
const led = ledXY(LED_ANGLES[pos]);
const ly = labelY(i);
const label = getLabel(pos);
if (!label) return null;
return (
<line
key={`cl-${pos}`}
x1={LABEL_RIGHT_X - 6}
y1={ly}
x2={led.x}
y2={led.y}
stroke={LINE_STROKE}
strokeWidth={1}
/>
);
})}
{/* Knob center */}
<circle cx={CX} cy={CY} r={KNOB_R} fill={`url(#${gradId})`} />
{/* LED dots on the circular ring */}
{ALL_POSITIONS.map((pos) => {
const { x, y } = ledXY(LED_ANGLES[pos]);
const isActive = activeSet.has(pos);
const fill = isActive ? COLOR_FILLS[activeColor] : INACTIVE_FILL;
return <circle key={`led-${pos}`} cx={x} cy={y} r={LED_DOT_R} fill={fill} />;
})}
{/* Left labels (right-aligned) */}
{LEFT_POSITIONS.map((pos, i) => {
const label = getLabel(pos);
if (!label) return null;
return (
<text
key={`lbl-${pos}`}
x={LABEL_LEFT_X}
y={labelY(i)}
textAnchor="end"
dominantBaseline="central"
fill={LABEL_FILL}
className="coral-knob-label"
>
{label}
</text>
);
})}
{/* Right labels (left-aligned) */}
{RIGHT_POSITIONS.map((pos, i) => {
const label = getLabel(pos);
if (!label) return null;
return (
<text
key={`lbl-${pos}`}
x={LABEL_RIGHT_X}
y={labelY(i)}
textAnchor="start"
dominantBaseline="central"
fill={LABEL_FILL}
className="coral-knob-label"
>
{label}
</text>
);
})}
</svg>
</div>
);
};