gui/renderers/ElementRenderer.js

/**
 * @class ElementRenderer
 * @description
 * Base class for all circuit element renderers in the JSCircuit Editor.
 *
 * This abstract renderer provides common functionality for rendering circuit elements
 * on the HTML5 canvas, including terminal rendering, label placement, selection
 * indicators, and hover effects.
 *
 * **Renderer Architecture:**
 * - Follows the Template Method pattern
 * - Provides common rendering utilities (terminals, labels, selection)
 * - Extended by specific renderers (ResistorRenderer, CapacitorRenderer, etc.)
 * - Uses delegation pattern for element-specific rendering logic
 *
 * **Common Rendering Features:**
 * - Terminal points (connection nodes)
 * - Element labels with automatic positioning
 * - Selection highlighting with customizable colors
 * - Hover effects for user interaction feedback
 * - Alignment guides for development/debugging
 *
 * @abstract
 * @example
 * class CustomRenderer extends ElementRenderer {
 *   render(element, isSelected, isHovered) {
 *     // Custom element rendering logic
 *     this.renderTerminals(element.nodes);
 *     this.renderLabel(element.label, x, y);
 *   }
 * }
 */
// ElementRenderer.js
export class ElementRenderer {
  /**
   * Creates a new ElementRenderer instance.
   *
   * @param {CanvasRenderingContext2D} context - The 2D rendering context for the canvas
   */
  constructor(context) {
    this.context = context;
    // Optional: show alignment guide markers (set to true to debug or visualize)
    this.showAlignmentGuide = false;
    // You can configure the guide color and size here:
    this.alignmentGuideColor = "red";
    this.alignmentGuideSize = 4; // pixels radius for the guide cross
  }

  /**
   * Renders a terminal as a small circle.
   * @param {Position} position - The terminal's position.
   */
  renderTerminal(position) {
    this.context.fillStyle = "black";
    this.context.beginPath();
    this.context.arc(position.x, position.y, 2, 0, Math.PI * 2);
    this.context.fill();

    if (this.showAlignmentGuide) {
      this.renderAlignmentGuide(position);
    }
  }

  /**
   * Renders a label at a given position.
   * @param {string} text - The label text.
   * @param {number} x - X-coordinate.
   * @param {number} y - Y-coordinate.
   */
  renderLabel(text, x, y) {
    this.context.fillStyle = "black";
    this.context.font = "9px Arial";
    this.context.textAlign = "center";
    this.context.textBaseline = "middle";
    this.context.fillText(text, x, y);
  }

  /**
   * Formats a component value with its unit for display
   * @param {string} propertyKey - The property key (e.g., 'resistance', 'capacitance')
   * @param {number} value - The numeric value
   * @returns {string} Formatted value with unit (e.g., "10 Ω", "1.5 μF")
   */
  formatValue(propertyKey, value) {
    if (value === undefined || value === null) return '';
    
    const formatters = {
      resistance: (val) => this.formatWithPrefix(val, 'Ω'),
      capacitance: (val) => this.formatWithPrefix(val, 'F'),
      inductance: (val) => this.formatWithPrefix(val, 'H'),
      critical_current: (val) => this.formatWithPrefix(val, 'A')
    };
    
    const formatter = formatters[propertyKey];
    return formatter ? formatter(value) : `${value}`;
  }

  /**
   * Formats a number with appropriate SI prefix and unit
   * @param {number} value - The numeric value
   * @param {string} unit - The base unit (e.g., 'Ω', 'F', 'H')
   * @returns {string} Formatted string with prefix and unit
   */
  formatWithPrefix(value, unit) {
    if (value === 0) return `0 ${unit}`;
    
    const prefixes = [
      { threshold: 1e9, symbol: 'G' },
      { threshold: 1e6, symbol: 'M' },
      { threshold: 1e3, symbol: 'k' },
      { threshold: 1, symbol: '' },
      { threshold: 1e-3, symbol: 'm' },
      { threshold: 1e-6, symbol: 'μ' },
      { threshold: 1e-9, symbol: 'n' },
      { threshold: 1e-12, symbol: 'p' },
      { threshold: 1e-15, symbol: 'f' }
    ];
    
    const absValue = Math.abs(value);
    for (const prefix of prefixes) {
      if (absValue >= prefix.threshold) {
        const scaledValue = value / prefix.threshold;
        const formattedValue = scaledValue % 1 === 0 ? scaledValue.toString() : scaledValue.toPrecision(3);
        return `${formattedValue} ${prefix.symbol}${unit}`;
      }
    }
    
    // For very small values, use scientific notation
    return `${value.toExponential(2)} ${unit}`;
  }

  /**
   * Renders element properties (label and/or value) below/beside the component
   * @param {Object} element - The element to render properties for
   * @param {number} centerX - Center X coordinate of the component
   * @param {number} centerY - Center Y coordinate of the component
   * @param {number} angle - Rotation angle in radians
   */
  renderProperties(element, centerX, centerY, angle = 0) {
    // Safety check: ensure element has required methods and properties
    if (!element || typeof element.getProperties !== 'function') {
      return; // Skip property rendering for invalid elements
    }

    const label = element.label ? element.label.value || element.label : null;
    const properties = element.getProperties();
    
    // Safety check for properties
    if (!properties || !properties.values) {
      return; // Skip if properties are not accessible
    }
    
    // Get the primary property value for this component type
    const primaryProperty = this.getPrimaryProperty(element);
    const propertyValue = primaryProperty ? properties.values[primaryProperty] : null;
    
    // Build the display text
    let displayText = '';
    
    if (label && propertyValue !== undefined && propertyValue !== null) {
      // Show both label and value: "R1=10 Ω"
      const formattedValue = this.formatValue(primaryProperty, propertyValue);
      displayText = `${label}=${formattedValue}`;
    } else if (label) {
      // Show only label: "R1"
      displayText = label;
    } else if (propertyValue !== undefined && propertyValue !== null) {
      // Show only value: "10 Ω"
      displayText = this.formatValue(primaryProperty, propertyValue);
    }
    
    // If nothing to display, return early
    if (!displayText) return;
    
    // Calculate text position based on component orientation
    const { textX, textY, textAlign } = this.calculateTextPosition(centerX, centerY, angle);
    
    // Render the text
    this.context.save();
    this.context.fillStyle = "black";
    this.context.font = "9px Arial";
    this.context.textAlign = textAlign;
    this.context.textBaseline = "middle";
    this.context.fillText(displayText, textX, textY);
    this.context.restore();
  }

  /**
   * Gets the primary property key for a component type
   * @param {Object} element - The element
   * @returns {string|null} The primary property key
   */
  getPrimaryProperty(element) {
    const typeMapping = {
      resistor: 'resistance',
      capacitor: 'capacitance',
      inductor: 'inductance',
      junction: 'inductance'
    };
    return typeMapping[element.type] || null;
  }

  /**
   * Calculates text position based on component center and rotation
   * @param {number} centerX - Component center X
   * @param {number} centerY - Component center Y
   * @param {number} angle - Rotation angle in radians
   * @returns {Object} Text positioning info {textX, textY, textAlign}
   */
  calculateTextPosition(centerX, centerY, angle) {
    const verticalOffsetDistance = 18; // Closer for vertical components
    const horizontalOffsetDistance = 18; // Smaller offset for horizontal components
    
    // Normalize angle to 0-2π range
    const normalizedAngle = ((angle % (2 * Math.PI)) + (2 * Math.PI)) % (2 * Math.PI);
    
    // Determine if component is more horizontal or vertical
    const isVertical = (normalizedAngle > Math.PI/4 && normalizedAngle < 3*Math.PI/4) ||
                      (normalizedAngle > 5*Math.PI/4 && normalizedAngle < 7*Math.PI/4);
    
    if (isVertical) {
      // For vertical components: label to the right, closer positioning
      return {
        textX: centerX + verticalOffsetDistance,
        textY: centerY,
        textAlign: 'left'
      };
    } else {
      // For horizontal components: label below, smaller offset
      return {
        textX: centerX,
        textY: centerY + horizontalOffsetDistance,
        textAlign: 'center'
      };
    }
  }

  /**
   * Optional: Renders an alignment guide (e.g., a small cross) at a given position.
   * This can be used to visualize the grid intersections.
   * @param {Position} position - The position to mark.
   */
  renderAlignmentGuide(position) {
    const ctx = this.context;
    const size = this.alignmentGuideSize;
    ctx.save();
    ctx.strokeStyle = this.alignmentGuideColor;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(position.x - size, position.y);
    ctx.lineTo(position.x + size, position.y);
    ctx.moveTo(position.x, position.y - size);
    ctx.lineTo(position.x, position.y + size);
    ctx.stroke();
    ctx.restore();
  }

  /**
   * Get the rotation angle in radians from element properties.
   * @param {Object} element - The element with properties.
   * @returns {number} Rotation angle in radians.
   */
  getElementRotation(element) {
    const orientationDegrees = element.properties?.values?.orientation || 0;
    return (orientationDegrees * Math.PI) / 180;
  }

  /**
   * Apply element rotation to the canvas context.
   * This should be called before drawing, and must be paired with restoreRotation().
   * @param {Object} element - The element with rotation properties.
   * @param {number} centerX - Center X coordinate for rotation.
   * @param {number} centerY - Center Y coordinate for rotation.
   */
  applyRotation(element, centerX, centerY) {
    const rotation = this.getElementRotation(element);
    if (rotation !== 0) {
      this.context.save();
      this.context.translate(centerX, centerY);
      this.context.rotate(rotation);
      this.context.translate(-centerX, -centerY);
      return true; // Indicates rotation was applied
    }
    return false; // No rotation applied
  }

  /**
   * Restore canvas context after rotation.
   * Only call this if applyRotation() returned true.
   */
  restoreRotation() {
    this.context.restore();
  }

  /**
   * Abstract method for rendering an element.
   * @param {Object} element - The element to render.
   */
  renderElement(element) {
    throw new Error("renderElement() must be implemented in derived classes.");
  }
}