Takazudo Modular Styleguide

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

Component Source

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

Story Source

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