PhaseDiagramCanvas.java

package org.hammer;

import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.util.logging.Logger;
import javax.swing.JPanel;
import org.hammer.audio.AudioCaptureService;
import org.hammer.audio.core.AudioBlock;
import org.hammer.audio.snapshot.PhaseScopeSnapshot;
import org.hammer.audio.ui.theme.PlotRenderTheme;

/**
 * Canvas for displaying phase diagram (X-Y plot of two channels).
 *
 * <p>Refactored to use AudioCaptureService and WaveformModel instead of direct singleton access.
 * Uses thread-safe model snapshots for rendering.
 *
 * @author chammer
 */
public class PhaseDiagramCanvas extends JPanel {

  private static final Logger LOGGER = Logger.getLogger(PhaseDiagramCanvas.class.getName());
  private static final int LEFT_MARGIN = 44;
  private static final int RIGHT_MARGIN = 12;
  private static final int TOP_MARGIN = 20;
  private static final int BOTTOM_MARGIN = 34;

  private AudioCaptureService audioCaptureService;

  public PhaseDiagramCanvas() {
    super(true);
    // Timer to periodically repaint at consistent interval for smooth display updates
    LOGGER.info("PhaseDiagramCanvas created");
    javax.swing.Timer t = new javax.swing.Timer(UiConstants.REFRESH_INTERVAL_MS, e -> repaint());
    t.start();
  }

  /**
   * Set the audio capture service to use for phase diagram data.
   *
   * @param service the AudioCaptureService
   */
  public void setAudioCaptureService(AudioCaptureService service) {
    LOGGER.info("AudioCaptureService set: " + (service != null));
    this.audioCaptureService = service;
  }

  @Override
  protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    Graphics2D g2 = (Graphics2D) g.create();
    try {
      PlotRenderTheme.applyQualityRendering(g2);
      paintPhaseScope(g2);
    } finally {
      g2.dispose();
    }
  }

  private void paintPhaseScope(Graphics2D g2) {
    if (LOGGER.isLoggable(java.util.logging.Level.FINE)) {
      LOGGER.fine("paintComponent called");
    }
    int width = Math.max(1, getWidth());
    int height = Math.max(1, getHeight());
    Rectangle plotBounds =
        new Rectangle(
            LEFT_MARGIN,
            TOP_MARGIN,
            Math.max(1, width - LEFT_MARGIN - RIGHT_MARGIN),
            Math.max(1, height - TOP_MARGIN - BOTTOM_MARGIN));
    PlotRenderTheme.drawPlotBackground(g2, getWidth(), getHeight(), plotBounds);
    PlotRenderTheme.drawGrid(g2, plotBounds, 6, 6);
    drawReferenceGrid(g2, plotBounds);
    PlotRenderTheme.drawTitle(g2, 10, 16, "Phase Scope");
    PlotRenderTheme.drawXAxisLabel(g2, plotBounds, "Left amplitude [-1..1]");
    PlotRenderTheme.drawYAxisLabel(g2, plotBounds, "Right amplitude [-1..1]");
    PlotRenderTheme.drawXTicks(
        g2, plotBounds, new double[] {0.0d, 0.5d, 1.0d}, new String[] {"-1", "0", "+1"});
    PlotRenderTheme.drawYTicks(
        g2, plotBounds, new double[] {0.0d, 0.5d, 1.0d}, new String[] {"+1", "0", "-1"});

    if (audioCaptureService == null) {
      LOGGER.warning("paintComponent: audioCaptureService is null");
      PlotRenderTheme.drawEmptyState(g2, plotBounds, "No audio service connected");
      return;
    }

    AudioBlock latestBlock = audioCaptureService.getLatestBlock();
    if (latestBlock == null) {
      PlotRenderTheme.drawEmptyState(g2, plotBounds, "Waiting for stereo samples...");
      return;
    }
    PhaseScopeSnapshot snapshot = PhaseScopeSnapshot.fromBlock(latestBlock);
    if (snapshot.frames() == 0) {
      PlotRenderTheme.drawEmptyState(g2, plotBounds, "Stereo data unavailable");
      return;
    }
    drawPhaseTrace(g2, plotBounds, snapshot);
  }

  private void drawReferenceGrid(Graphics2D g2, Rectangle plotBounds) {
    int centerX = plotBounds.x + plotBounds.width / 2;
    int centerY = plotBounds.y + plotBounds.height / 2;
    int radius = Math.max(4, Math.min(plotBounds.width, plotBounds.height) / 2 - 12);
    g2.setColor(PlotRenderTheme.AXIS_COLOR);
    g2.setStroke(PlotRenderTheme.AXIS_STROKE);
    g2.drawLine(plotBounds.x + 6, centerY, plotBounds.x + plotBounds.width - 6, centerY);
    g2.drawLine(centerX, plotBounds.y + 6, centerX, plotBounds.y + plotBounds.height - 6);
    g2.setStroke(PlotRenderTheme.GRID_STROKE);
    for (int step = 1; step <= 4; step++) {
      int currentRadius = radius * step / 4;
      g2.setColor(step == 4 ? PlotRenderTheme.GRID_MAJOR : PlotRenderTheme.GRID_MINOR);
      g2.drawOval(
          centerX - currentRadius, centerY - currentRadius, 2 * currentRadius, 2 * currentRadius);
    }
  }

  private void drawPhaseTrace(Graphics2D g2, Rectangle plotBounds, PhaseScopeSnapshot snapshot) {
    float[] left = snapshot.leftView();
    float[] right = snapshot.rightView();
    int pointCount = Math.min(left.length, right.length);
    if (pointCount < 2) {
      return;
    }

    int centerX = plotBounds.x + plotBounds.width / 2;
    int centerY = plotBounds.y + plotBounds.height / 2;
    int radius = Math.max(4, Math.min(plotBounds.width, plotBounds.height) / 2 - 12);
    int[] xPoints = new int[pointCount];
    int[] yPoints = new int[pointCount];
    for (int i = 0; i < pointCount; i++) {
      double xNorm = Math.max(-1d, Math.min(1d, left[i]));
      double yNorm = Math.max(-1d, Math.min(1d, right[i]));
      xPoints[i] = centerX + (int) Math.round(xNorm * radius);
      yPoints[i] = centerY - (int) Math.round(yNorm * radius);
    }

    g2.setColor(PlotRenderTheme.PHASE_TRACE);
    g2.setStroke(PlotRenderTheme.THIN_TRACE_STROKE);
    g2.drawPolyline(xPoints, yPoints, pointCount);
  }
}