MDX/CoralKnob
Connected Figures
Custom Labels
Default
Engine Active
Engine Multiple Active
In Figure Wrapper
Midi Mode
Midi Mode In Figure
Voice Mode
Voice Mode In Figure
White Color
coral-knob.tsx
import { useId, useMemo } from 'preact/hooks';
// CSS moved to styles/global.css @import — zfb next.31 rejects CSS output from
// the islands bundle; styleguide-v2 lazy-loads coral-knob.stories via a
// 'use client' island, pulling coral-knob.css into islands.css which the
// emitter now rejects. Also imported in styleguide-v2/src/styles/global.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>
);
};
coral-knob-figure.tsx
import { CoralKnob, type CoralKnobFigureProps } from './coral-knob';
const captionClass = 'text-center text-zd-subtext text-sm mt-vgap-xs';
/**
* Responsive wrapper for CoralKnob in MDX articles.
* - screen < lg: width 100%
* - screen >= lg: knob centered at max 3/4 (75%); caption spans full width
*/
export const CoralKnobFigure = ({ caption, connected, ...knobProps }: CoralKnobFigureProps) => {
return (
<figure
className={`w-full pt-vgap-sm ${connected ? 'pb-0' : 'pb-vgap-lg'}`}
aria-label={caption ? undefined : 'OXI Coral knob diagram'}
>
<div className="lg:mx-auto lg:max-w-3/4">
<CoralKnob {...knobProps} />
</div>
{caption && <figcaption className={captionClass}>{caption}</figcaption>}
</figure>
);
};
coral-knob.stories.tsx
import { CoralKnob } from './coral-knob';
import { CoralKnobFigure } from './coral-knob-figure';
/**
* CoralKnob Component
*
* Visualizes the OXI Coral center encoder knob with its 10-position LED ring.
* Positions 1-5 are on the left (bottom to top), positions 6-10 are on the right (top to bottom).
* Supports engine, voice, midi, and custom label modes.
*/
export const meta = {
title: 'MDX/CoralKnob',
};
/**
* Default engine mode — all LEDs inactive
*/
export const Default = () => <CoralKnob />;
/**
* Engine mode with the 2 OP FM engine selected (position 3)
*/
export const EngineActive = () => <CoralKnob mode="engine" active={[3]} activeColor="pink" />;
/**
* Engine mode with multiple LEDs active
*/
export const EngineMultipleActive = () => (
<CoralKnob mode="engine" active={[1, 5, 6, 10]} activeColor="pink" />
);
/**
* Voice mode — shows Voice 1 through Voice 8, positions 9-10 unused
*/
export const VoiceMode = () => <CoralKnob mode="voice" active={[2]} activeColor="turquoise" />;
/**
* MIDI mode — shows MIDI Ch 1-8, positions 9-10 unused
*/
export const MidiMode = () => <CoralKnob mode="midi" active={[5]} activeColor="orange" />;
/**
* Custom labels mode
*/
export const CustomLabels = () => (
<CoralKnob
mode="custom"
labels={['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']}
active={[1, 6]}
activeColor="white"
/>
);
/**
* White active color
*/
export const WhiteColor = () => <CoralKnob mode="engine" active={[7]} activeColor="white" />;
/**
* Wrapped in CoralKnobFigure for responsive article layout with caption
*/
export const InFigureWrapper = () => (
<div style={{ width: 800, border: '1px dashed rgba(255,255,255,0.2)' }}>
<CoralKnobFigure
mode="engine"
active={[3]}
activeColor="pink"
caption="2 OP FMエンジンが選択された状態"
/>
</div>
);
/**
* Connected consecutive figures (no bottom padding on first)
*/
export const ConnectedFigures = () => (
<div style={{ width: 800, border: '1px dashed rgba(255,255,255,0.2)' }}>
<CoralKnobFigure
mode="engine"
active={[1]}
activeColor="pink"
caption="2 VCO Virtual Analogエンジン"
connected
/>
<CoralKnobFigure mode="engine" active={[3]} activeColor="pink" caption="2 OP FMエンジン" />
</div>
);
/**
* Voice mode in figure wrapper
*/
export const VoiceModeInFigure = () => (
<div style={{ width: 800, border: '1px dashed rgba(255,255,255,0.2)' }}>
<CoralKnobFigure
mode="voice"
active={[4]}
activeColor="turquoise"
caption="ボイス4が選択された状態"
/>
</div>
);
/**
* MIDI mode in figure wrapper
*/
export const MidiModeInFigure = () => (
<div style={{ width: 800, border: '1px dashed rgba(255,255,255,0.2)' }}>
<CoralKnobFigure
mode="midi"
active={[1]}
activeColor="orange"
caption="MIDIチャンネル1が選択された状態"
/>
</div>
);